Merge pull request #29 from 3252a8/dev
PayKilla provider, Telegram anti-flood, install wizard and Remnashop migration (test)
This commit is contained in:
@@ -16,7 +16,9 @@ frontend/node_modules/
|
||||
docs-site/node_modules/
|
||||
docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/public/demo/runtime/
|
||||
docs-site/src/content/docs/
|
||||
frontend-nginx-dist/
|
||||
deploy/compose/docker-compose-dev.yml
|
||||
data/*
|
||||
!data/tariffs.example.json
|
||||
@@ -51,6 +53,11 @@ bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp.css
|
||||
backend/bot/app/web/templates/subscription_webapp.js
|
||||
backend/bot/app/web/templates/subscription_webapp.min.*.js
|
||||
@@ -67,6 +74,11 @@ backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.js
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.br
|
||||
backend/bot/app/web/templates/subscription_webapp_docs_demo.*.css.gz
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
**/__pycache__/
|
||||
|
||||
+7
-5
@@ -71,16 +71,18 @@ WEB_SERVER_PORT=8080
|
||||
FRONTEND_PORT=8082
|
||||
|
||||
# Reverse proxy IPs/CIDRs trusted for X-Forwarded-For.
|
||||
# Keep loopback for local proxy; add your proxy network if needed.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1
|
||||
# Keep loopback and private network ranges so payment provider IP allowlists
|
||||
# see the original webhook sender behind Docker/LAN/Kubernetes proxies.
|
||||
TRUSTED_PROXIES=127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7
|
||||
|
||||
# ─── Anonymous install telemetry (opt-out) ──────────────────────────────
|
||||
# Once a day the worker sends a single anonymous "heartbeat" so the project
|
||||
# maintainer can see how many installs are active and which versions/OSes are
|
||||
# used. It contains an opaque random install id and coarse facts only:
|
||||
# version, OS/arch, Python version, language, enabled payment providers and a
|
||||
# user-count RANGE (e.g. "51-200"). No bot token, domain, user data or any
|
||||
# personal information is ever sent. Full details: docs/configuration/telemetry.md
|
||||
# version, official/custom image provenance, OS/arch, Python version, language,
|
||||
# enabled payment providers and a user-count RANGE (e.g. "51-200"). No bot
|
||||
# token, domain, user data or any personal information is ever sent.
|
||||
# Full details: docs/configuration/telemetry.md
|
||||
#
|
||||
# Set to False to disable, or toggle it any time in Admin -> System ->
|
||||
# "Anonymous install analytics" (applies without a restart).
|
||||
|
||||
@@ -2,11 +2,11 @@ name: Docker build & push (reusable)
|
||||
|
||||
# Reusable workflow that builds the three image targets defined in
|
||||
# deploy/docker/Dockerfile (backend, worker, frontend) and optionally pushes
|
||||
# them to both ghcr.io and Docker Hub under the 3252a8/ namespace.
|
||||
# them to the selected registries under the repository owner's namespace.
|
||||
#
|
||||
# Called by:
|
||||
# - docker-dev.yml (tag_mode: dev, push: true) on pushes to dev
|
||||
# - docker-release.yml (tag_mode: release, push: true) on pushes to main
|
||||
# - docker-release.yml (tag_mode: release, push: true) on release tags
|
||||
# - ci.yml (tag_mode: dev, push: false) on pull requests
|
||||
|
||||
on:
|
||||
@@ -20,6 +20,10 @@ on:
|
||||
description: "Tagging strategy: 'dev' or 'release'"
|
||||
type: string
|
||||
required: true
|
||||
publish_dockerhub:
|
||||
description: "Include Docker Hub tags and login when pushing"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# No permissions block here on purpose: a reusable workflow cannot request more
|
||||
# than its caller grants, so the token scope is set by each caller
|
||||
@@ -78,19 +82,36 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: inputs.push
|
||||
if: inputs.push && inputs.publish_dockerhub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Resolve image namespaces
|
||||
id: image_namespaces
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
github_owner="${{ github.repository_owner }}"
|
||||
echo "github_owner=${github_owner,,}" >> "$GITHUB_OUTPUT"
|
||||
dockerhub_owner="${github_owner,,}"
|
||||
if [ "${{ inputs.push }}" = "true" ] && [ "${{ inputs.publish_dockerhub }}" = "true" ]; then
|
||||
if [ -z "$DOCKERHUB_USERNAME" ]; then
|
||||
echo "::error::DOCKERHUB_USERNAME secret is required for Docker Hub publishing"
|
||||
exit 1
|
||||
fi
|
||||
dockerhub_owner="${DOCKERHUB_USERNAME,,}"
|
||||
fi
|
||||
echo "dockerhub_owner=$dockerhub_owner" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
3252a8/${{ matrix.image }}
|
||||
ghcr.io/3252a8/${{ matrix.image }}
|
||||
name=${{ steps.image_namespaces.outputs.dockerhub_owner }}/${{ matrix.image }},enable=${{ inputs.push && inputs.publish_dockerhub }}
|
||||
name=ghcr.io/${{ steps.image_namespaces.outputs.github_owner }}/${{ matrix.image }},enable=true
|
||||
tags: |
|
||||
type=raw,value=dev,enable=${{ inputs.tag_mode == 'dev' }}
|
||||
type=raw,value=latest,enable=${{ inputs.tag_mode == 'release' }}
|
||||
@@ -111,6 +132,7 @@ jobs:
|
||||
# (the ref is the tag, not a branch) so release images stay un-suffixed.
|
||||
build-args: |
|
||||
GITHUB_REF_NAME=${{ inputs.tag_mode == 'release' && 'main' || github.ref_name }}
|
||||
REMNAWAVE_MINISHOP_BUILD_PROVENANCE=${{ github.repository == '3252a8/remnawave-minishop' && 'official' || 'custom' }}
|
||||
cache-from: type=gha,scope=${{ matrix.target }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.target }}
|
||||
provenance: false
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: PR checks
|
||||
|
||||
# Runs on pull requests into main (typically from dev) and into dev (typically
|
||||
# from feature/* branches): lint + format checks and a no-push image build to
|
||||
# prove the Docker images still build.
|
||||
# from feature/* branches): lint + format checks, a demo settings-manifest
|
||||
# drift guard, and a no-push image build to prove the Docker images still build.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -56,6 +56,30 @@ jobs:
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
|
||||
demo-manifest:
|
||||
name: Demo settings manifest in sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: backend/requirements.txt
|
||||
|
||||
- name: Install backend deps + pytest
|
||||
run: pip install -r backend/requirements.txt pytest
|
||||
|
||||
# Fails if admin_settings_manifest.py changed without regenerating the
|
||||
# demo snapshot. Fix: `python scripts/export_settings_manifest.py` then
|
||||
# `npx --prefix frontend prettier --write \
|
||||
# src/lib/webapp/settingsManifest.generated.json`, and commit the result.
|
||||
- name: Check demo settings manifest is in sync
|
||||
run: python -m pytest tests/test_settings_manifest_demo_sync.py -q
|
||||
|
||||
build:
|
||||
name: Docker build
|
||||
uses: ./.github/workflows/_docker-build-push.yml
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Dev images
|
||||
|
||||
# On every push to the dev branch, build all three images and push them to
|
||||
# ghcr.io and Docker Hub tagged `dev` and `dev-<short-sha>`.
|
||||
# GHCR tagged `dev`. Docker Hub dev images are published by GitLab CI.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -22,4 +22,5 @@ jobs:
|
||||
with:
|
||||
push: true
|
||||
tag_mode: dev
|
||||
publish_dockerhub: false
|
||||
secrets: inherit
|
||||
|
||||
@@ -17,6 +17,7 @@ docs-site/.astro/
|
||||
docs-site/dist/
|
||||
docs-site/public/demo/runtime/
|
||||
docs-site/src/content/docs/
|
||||
frontend-nginx-dist/
|
||||
|
||||
# WebApp build artifacts (regenerated by `npm run build:webapp` / Docker build)
|
||||
bot/app/web/templates/subscription_webapp.css
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
stages:
|
||||
- docker
|
||||
|
||||
workflow:
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "dev"'
|
||||
- when: never
|
||||
|
||||
variables:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
DOCKER_DRIVER: overlay2
|
||||
DOCKER_HOST: tcp://docker:2375
|
||||
DOCKER_TLS_CERTDIR: ""
|
||||
GIT_DEPTH: "0"
|
||||
|
||||
docker-dev:
|
||||
stage: docker
|
||||
image: docker:27.5.1
|
||||
services:
|
||||
- name: docker:27.5.1-dind
|
||||
alias: docker
|
||||
interruptible: true
|
||||
parallel:
|
||||
matrix:
|
||||
- TARGET: backend
|
||||
IMAGE: remnawave-minishop-backend
|
||||
- TARGET: worker
|
||||
IMAGE: remnawave-minishop-worker
|
||||
- TARGET: frontend
|
||||
IMAGE: remnawave-minishop-frontend
|
||||
before_script:
|
||||
- apk add --no-cache git
|
||||
- test -n "$DOCKERHUB_USERNAME"
|
||||
- test -n "$DOCKERHUB_TOKEN"
|
||||
- echo "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin
|
||||
- docker buildx create --name gitlab-builder --driver docker-container --use
|
||||
- docker buildx inspect --bootstrap
|
||||
script:
|
||||
- git fetch origin dev --tags
|
||||
- |
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
|
||||
echo "A newer dev commit exists; skipping Docker Hub build for $IMAGE."
|
||||
exit 0
|
||||
fi
|
||||
- |
|
||||
provenance="${REMNAWAVE_MINISHOP_BUILD_PROVENANCE:-}"
|
||||
if [ -z "$provenance" ]; then
|
||||
dockerhub_owner="$(printf '%s' "${DOCKERHUB_USERNAME:-}" | tr '[:upper:]' '[:lower:]')"
|
||||
if [ "${CI_PROJECT_PATH:-}" = "3252a8/remnawave-minishop" ] || [ "$dockerhub_owner" = "3252a8" ]; then
|
||||
provenance="official"
|
||||
else
|
||||
provenance="custom"
|
||||
fi
|
||||
fi
|
||||
docker buildx build \
|
||||
--load \
|
||||
--platform linux/amd64 \
|
||||
--file deploy/docker/Dockerfile \
|
||||
--target "$TARGET" \
|
||||
--build-arg "CI_COMMIT_REF_NAME=$CI_COMMIT_REF_NAME" \
|
||||
--build-arg "REMNAWAVE_MINISHOP_BUILD_PROVENANCE=$provenance" \
|
||||
--build-arg "BUILDKIT_INLINE_CACHE=1" \
|
||||
--cache-from "type=registry,ref=$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
||||
--tag "$DOCKERHUB_USERNAME/$IMAGE:dev" \
|
||||
.
|
||||
- git fetch origin dev --tags
|
||||
- |
|
||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/dev)" ]; then
|
||||
echo "A newer dev commit exists; skipping Docker Hub push for $IMAGE."
|
||||
exit 0
|
||||
fi
|
||||
- docker push "$DOCKERHUB_USERNAME/$IMAGE:dev"
|
||||
@@ -16,7 +16,7 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- Web App / Mini App с входом через Telegram или email;
|
||||
- встроенные инструкции установки в Mini App: личный экран `/install` и публичная ссылка `/s/<token>` для передачи инструкции;
|
||||
- пробный период, промокоды и реферальная программа;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket и Telegram Stars;
|
||||
- оплата через YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay, Heleket, PayKilla и Telegram Stars;
|
||||
- тикеты поддержки в Web App и внешняя ссылка на поддержку;
|
||||
- раздел "Мои устройства" при включенном `MY_DEVICES_SECTION_ENABLED`.
|
||||
|
||||
@@ -43,8 +43,8 @@ Remnawave Minishop - Telegram-бот и Web App (Mini App) для продажи
|
||||
- [Telegram-авторизация](docs/features/telegram-auth.md) и [вход по email](docs/features/email-login.md) - настройка BotFather/OAuth и SMTP-логина.
|
||||
- [Поддержка пользователей / тикеты](docs/features/support.md) - тикеты в Mini App, входящий список админки, уведомления, лимиты и внешняя ссылка поддержки.
|
||||
- [Темы Web App](docs/features/webapp-themes.md) - кастомные темы, настройка внешнего вида, логотипы, CSS/ассеты и пайплайн создания новой темы.
|
||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с других ботов; сейчас описан `remnawave-tg-shop`.
|
||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) - готовый сценарий для legacy-стека.
|
||||
- [Миграции](docs/migrations/index.md) - готовые сценарии переноса с `remnawave-tg-shop` и Remnashop.
|
||||
- [Миграция с remnawave-tg-shop](docs/migrations/remnawave-tg-shop.md) и [Remnashop](docs/migrations/remnashop.md) - сценарии через общий install wizard.
|
||||
|
||||
## Совместимость
|
||||
|
||||
@@ -90,6 +90,7 @@ docker compose logs -f backend worker frontend
|
||||
- `WEBAPP_SESSION_SECRET`, `WEBHOOK_SECRET_TOKEN` - стабильные секреты;
|
||||
- `SUBSCRIPTION_MINI_APP_URL` - публичный HTTPS URL Mini App/frontend, например `https://app.domain.com/`;
|
||||
- `PANEL_API_URL`, `PANEL_API_KEY`, `PANEL_WEBHOOK_SECRET` - доступ к Remnawave;
|
||||
- `TRUSTED_PROXIES` - оставьте дефолт для Docker/Caddy/Nginx/Newt или укажите IP/CIDR своего reverse proxy, чтобы IP allowlist платежных webhook видел реального провайдера;
|
||||
- остальные настройки удобнее задать в Web App админке.
|
||||
|
||||
В Remnawave Panel укажите `WEBHOOK_URL` как публичный адрес Minishop с путем `/webhook/panel`, например `https://app.example.com/webhook/panel`. Секрет вебхука задается в самой Remnawave Panel; это же значение вставьте в `PANEL_WEBHOOK_SECRET` в `.env` или в **Система -> Настройки -> Remnawave Panel** в админке.
|
||||
@@ -98,10 +99,12 @@ docker compose logs -f backend worker frontend
|
||||
|
||||
Для каталога тарифов используется `TARIFFS_CONFIG_PATH` со значением по умолчанию `data/tariffs.json`. Пример формата лежит в [data/tariffs.example.json](data/tariffs.example.json), подробности - в [docs/features/tariffs.md](docs/features/tariffs.md).
|
||||
|
||||
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes`, кеша логотипа Web App и animated emoji:
|
||||
В Docker этот файл должен быть доступен не только `backend` и `worker`, но и одноразовому сервису `migrate`: мигратор читает каталог тарифов при привязке существующих подписок к тарифу по умолчанию. В текущих compose-файлах весь `/app/data` уже смонтирован в `migrate`, `backend` и `worker`; если переносите compose вручную, сохраните одинаковый mount для всех трех сервисов.
|
||||
|
||||
В compose-примерах `/app/data` монтируется из папки `./data` рядом с `docker-compose.yml`. Заранее создайте каталог и отдайте его пользователю контейнера. Это нужно для сохранения `data/tariffs.json`, каталога тем `data/themes` и кеша логотипа Web App:
|
||||
|
||||
```bash
|
||||
mkdir -p data/themes data/webapp-logo data/webapp-emoji
|
||||
mkdir -p data/themes data/webapp-logo data/tariffs
|
||||
touch data/locales-overrides.json
|
||||
chown -R 10001:10001 data
|
||||
chmod -R u+rwX data
|
||||
|
||||
@@ -17,6 +17,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
from bot.middlewares.db_session import DBSessionMiddleware
|
||||
from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from bot.middlewares.update_antiflood import UpdateAntiFloodMiddleware
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -38,6 +39,7 @@ def build_dispatcher(
|
||||
dp["i18n_instance"] = i18n_instance
|
||||
dp["async_session_factory"] = async_session_factory
|
||||
|
||||
dp.update.outer_middleware(UpdateAntiFloodMiddleware(settings=settings))
|
||||
dp.update.outer_middleware(DBSessionMiddleware(async_session_factory))
|
||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||
|
||||
@@ -9,6 +9,7 @@ from bot.app.web.admin_api_impl import (
|
||||
backups as _backups,
|
||||
broadcast as _broadcast,
|
||||
common as _common,
|
||||
health as _health,
|
||||
logs as _logs,
|
||||
panel as _panel,
|
||||
payments as _payments,
|
||||
@@ -28,6 +29,7 @@ _MODULES = (
|
||||
_runtime,
|
||||
_auth,
|
||||
_common,
|
||||
_health,
|
||||
_stats,
|
||||
_users,
|
||||
_payments,
|
||||
|
||||
@@ -1,5 +1,165 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .common import _panel_user_connection_activity
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
|
||||
from bot.utils.ttl_cache import AsyncTTLCache
|
||||
|
||||
|
||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED = "active_never_connected"
|
||||
BROADCAST_TARGETS = {
|
||||
"all",
|
||||
"active",
|
||||
"inactive",
|
||||
"expired",
|
||||
"never",
|
||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED,
|
||||
}
|
||||
PANEL_ACTIVITY_LOOKUP_CONCURRENCY = 10
|
||||
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES: Dict[tuple[int, int], AsyncTTLCache] = {}
|
||||
|
||||
|
||||
def _resolve_panel_service(request: web.Request) -> Any:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
return getattr(subscription_service, "panel_service", None)
|
||||
|
||||
|
||||
async def _active_subscription_panel_uuids_by_user(
|
||||
session: AsyncSession,
|
||||
) -> Dict[int, List[str]]:
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = (
|
||||
select(Subscription.user_id, Subscription.panel_user_uuid)
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(
|
||||
User.is_banned == False,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
Subscription.panel_user_uuid.is_not(None),
|
||||
Subscription.panel_user_uuid != "",
|
||||
)
|
||||
.order_by(Subscription.user_id.asc(), Subscription.end_date.desc())
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
|
||||
grouped: Dict[int, List[str]] = defaultdict(list)
|
||||
seen: Dict[int, set[str]] = defaultdict(set)
|
||||
for user_id, panel_uuid in result.all():
|
||||
user_id_int = int(user_id)
|
||||
panel_uuid_str = str(panel_uuid or "").strip()
|
||||
if panel_uuid_str and panel_uuid_str not in seen[user_id_int]:
|
||||
grouped[user_id_int].append(panel_uuid_str)
|
||||
seen[user_id_int].add(panel_uuid_str)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
async def _panel_connection_status(panel_service: Any, panel_uuid: str) -> str:
|
||||
try:
|
||||
panel_user = await panel_service.get_user_by_uuid(panel_uuid)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch panel user activity uuid=%s: %s", panel_uuid, exc)
|
||||
return "unknown"
|
||||
activity = _panel_user_connection_activity(panel_user)
|
||||
return str(activity.get("status") or "unknown")
|
||||
|
||||
|
||||
async def _user_ids_with_active_subscription_never_connected(
|
||||
session: AsyncSession,
|
||||
panel_service: Any,
|
||||
) -> List[int]:
|
||||
panel_uuids_by_user = await _active_subscription_panel_uuids_by_user(session)
|
||||
semaphore = asyncio.Semaphore(PANEL_ACTIVITY_LOOKUP_CONCURRENCY)
|
||||
|
||||
async def lookup(panel_uuid: str) -> str:
|
||||
async with semaphore:
|
||||
return await _panel_connection_status(panel_service, panel_uuid)
|
||||
|
||||
panel_uuids = list(
|
||||
dict.fromkeys(
|
||||
panel_uuid
|
||||
for user_panel_uuids in panel_uuids_by_user.values()
|
||||
for panel_uuid in user_panel_uuids
|
||||
)
|
||||
)
|
||||
statuses_by_uuid = dict(
|
||||
zip(
|
||||
panel_uuids,
|
||||
await asyncio.gather(*(lookup(uuid) for uuid in panel_uuids)),
|
||||
)
|
||||
)
|
||||
|
||||
user_ids: List[int] = []
|
||||
for user_id, panel_uuids in panel_uuids_by_user.items():
|
||||
statuses = [statuses_by_uuid.get(panel_uuid, "unknown") for panel_uuid in panel_uuids]
|
||||
if statuses and all(status == "never" for status in statuses):
|
||||
user_ids.append(user_id)
|
||||
return user_ids
|
||||
|
||||
|
||||
def _admin_broadcast_audience_counts_cache(settings: Settings) -> Optional[AsyncTTLCache]:
|
||||
ttl_seconds = int(
|
||||
getattr(settings, "ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS", 30) or 0
|
||||
)
|
||||
if ttl_seconds <= 0:
|
||||
return None
|
||||
cache_key = (id(settings), ttl_seconds)
|
||||
cache = _ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES.get(cache_key)
|
||||
if cache is None:
|
||||
cache = AsyncTTLCache(
|
||||
ttl_seconds=ttl_seconds,
|
||||
settings=settings,
|
||||
namespace="admin:broadcast_audience_counts",
|
||||
)
|
||||
_ADMIN_BROADCAST_AUDIENCE_COUNT_CACHES[cache_key] = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def _load_broadcast_audience_counts(
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: Any,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
cache = _admin_broadcast_audience_counts_cache(settings)
|
||||
if cache is None:
|
||||
return await _load_broadcast_audience_counts_uncached(
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
)
|
||||
cache_key = "with-panel" if panel_service is not None else "without-panel"
|
||||
return await cache.get_or_load(
|
||||
cache_key,
|
||||
lambda: _load_broadcast_audience_counts_uncached(
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _load_broadcast_audience_counts_uncached(
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: Any,
|
||||
) -> Dict[str, Optional[int]]:
|
||||
async with async_session_factory() as session:
|
||||
counts: Dict[str, Optional[int]] = {
|
||||
"all": await user_dal.count_all_active_users_for_broadcast(session),
|
||||
"active": await user_dal.count_users_with_active_subscription_for_broadcast(session),
|
||||
"inactive": await user_dal.count_users_without_active_subscription_for_broadcast(
|
||||
session
|
||||
),
|
||||
"expired": await user_dal.count_users_with_expired_subscription_for_broadcast(session),
|
||||
"never": await user_dal.count_users_without_any_subscription_for_broadcast(session),
|
||||
BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED: None,
|
||||
}
|
||||
if panel_service is not None:
|
||||
counts[BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED] = len(
|
||||
await _user_ids_with_active_subscription_never_connected(
|
||||
session,
|
||||
panel_service,
|
||||
)
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
@@ -9,7 +169,7 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
target = str(payload.get("target") or "all").strip().lower()
|
||||
if not text:
|
||||
return _error(400, "empty_text")
|
||||
if target not in {"all", "active", "inactive", "expired"}:
|
||||
if target not in BROADCAST_TARGETS:
|
||||
target = "all"
|
||||
|
||||
queue_manager = get_queue_manager()
|
||||
@@ -18,12 +178,22 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
if target == "active":
|
||||
if target == BROADCAST_TARGET_ACTIVE_NEVER_CONNECTED:
|
||||
panel_service = _resolve_panel_service(request)
|
||||
if panel_service is None:
|
||||
return _error(503, "panel_service_unavailable")
|
||||
user_ids = await _user_ids_with_active_subscription_never_connected(
|
||||
session,
|
||||
panel_service,
|
||||
)
|
||||
elif target == "active":
|
||||
user_ids = await user_dal.get_user_ids_with_active_subscription(session)
|
||||
elif target == "inactive":
|
||||
user_ids = await user_dal.get_user_ids_without_active_subscription(session)
|
||||
elif target == "expired":
|
||||
user_ids = await user_dal.get_user_ids_with_expired_subscription(session)
|
||||
elif target == "never":
|
||||
user_ids = await user_dal.get_user_ids_without_any_subscription(session)
|
||||
else:
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
@@ -54,3 +224,19 @@ async def admin_broadcast_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
|
||||
return _ok({"queued": sent, "failed": failed, "target": target})
|
||||
|
||||
|
||||
async def admin_broadcast_audience_counts_route(request: web.Request) -> web.Response:
|
||||
"""Return how many users each broadcast audience currently resolves to."""
|
||||
_require_admin_user_id(request)
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
panel_service = _resolve_panel_service(request)
|
||||
counts = await _load_broadcast_audience_counts(
|
||||
settings,
|
||||
async_session_factory,
|
||||
panel_service,
|
||||
)
|
||||
|
||||
return _ok({"counts": counts})
|
||||
|
||||
@@ -22,6 +22,175 @@ async def _read_json(request: web.Request) -> Dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
_PANEL_LAST_CONNECTED_KEYS = (
|
||||
"onlineAt",
|
||||
"online_at",
|
||||
"lastSeenAt",
|
||||
"last_seen_at",
|
||||
"lastConnectedAt",
|
||||
"last_connected_at",
|
||||
"lastConnectionAt",
|
||||
"last_connection_at",
|
||||
)
|
||||
_PANEL_CONNECTION_MARKER_KEYS = (
|
||||
*_PANEL_LAST_CONNECTED_KEYS,
|
||||
"firstConnectedAt",
|
||||
"first_connected_at",
|
||||
"lastConnectedNodeUuid",
|
||||
"last_connected_node_uuid",
|
||||
)
|
||||
_PANEL_CONNECTION_MARKER_OBJECT_KEYS = ("lastConnectedNode", "last_connected_node")
|
||||
_PANEL_TRAFFIC_OBJECT_KEYS = ("userTraffic", "user_traffic", "traffic", "trafficStats")
|
||||
_PANEL_TRAFFIC_USED_KEYS = (
|
||||
"lifetimeUsedTrafficBytes",
|
||||
"lifetime_used_traffic_bytes",
|
||||
"usedTrafficBytes",
|
||||
"used_traffic_bytes",
|
||||
"trafficUsedBytes",
|
||||
"traffic_used_bytes",
|
||||
"downloadBytes",
|
||||
"download_bytes",
|
||||
"uploadBytes",
|
||||
"upload_bytes",
|
||||
)
|
||||
|
||||
|
||||
def _panel_user_payload(panel_user_data: Any) -> Dict[str, Any]:
|
||||
if not isinstance(panel_user_data, dict):
|
||||
return {}
|
||||
response = panel_user_data.get("response")
|
||||
if isinstance(response, dict) and not any(
|
||||
key in panel_user_data
|
||||
for key in ("uuid", "shortUuid", "subscriptionUrl", "userTraffic", "status")
|
||||
):
|
||||
return response
|
||||
return panel_user_data
|
||||
|
||||
|
||||
def _coerce_panel_datetime(value: Any) -> Optional[str]:
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
if isinstance(value, (int, float)):
|
||||
if value <= 0:
|
||||
return None
|
||||
seconds = float(value) / 1000.0 if value > 10_000_000_000 else float(value)
|
||||
try:
|
||||
return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
|
||||
except (OSError, OverflowError, ValueError):
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text or text.lower() in {"0", "null", "none", "never"}:
|
||||
return None
|
||||
if text.isdigit():
|
||||
return _coerce_panel_datetime(int(text))
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.isoformat()
|
||||
|
||||
|
||||
def _coerce_panel_int(value: Any) -> Optional[int]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _panel_nested_dicts(panel_user: Dict[str, Any], keys: Tuple[str, ...]) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
for key in keys:
|
||||
value = panel_user.get(key)
|
||||
if isinstance(value, dict):
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
def _panel_user_connection_containers(panel_user: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
traffic_containers = _panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)
|
||||
marker_containers = _panel_nested_dicts(
|
||||
panel_user,
|
||||
_PANEL_CONNECTION_MARKER_OBJECT_KEYS,
|
||||
)
|
||||
for traffic_container in traffic_containers:
|
||||
marker_containers.extend(
|
||||
_panel_nested_dicts(traffic_container, _PANEL_CONNECTION_MARKER_OBJECT_KEYS)
|
||||
)
|
||||
return [panel_user, *traffic_containers, *marker_containers]
|
||||
|
||||
|
||||
def _panel_user_last_connected_at(panel_user_data: Any) -> Optional[str]:
|
||||
panel_user = _panel_user_payload(panel_user_data)
|
||||
if not panel_user:
|
||||
return None
|
||||
for container in _panel_user_connection_containers(panel_user):
|
||||
for key in _PANEL_LAST_CONNECTED_KEYS:
|
||||
connected_at = _coerce_panel_datetime(container.get(key))
|
||||
if connected_at:
|
||||
return connected_at
|
||||
return None
|
||||
|
||||
|
||||
def _panel_user_positive_traffic_bytes(panel_user: Dict[str, Any]) -> bool:
|
||||
containers = [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]
|
||||
for container in containers:
|
||||
for key in _PANEL_TRAFFIC_USED_KEYS:
|
||||
value = _coerce_panel_int(container.get(key))
|
||||
if value is not None and value > 0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _panel_user_has_connection_marker(panel_user: Dict[str, Any]) -> bool:
|
||||
for container in _panel_user_connection_containers(panel_user):
|
||||
for key in _PANEL_CONNECTION_MARKER_KEYS:
|
||||
if key in container:
|
||||
return True
|
||||
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
|
||||
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
|
||||
if key in container:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _panel_user_has_connected_marker_value(panel_user: Dict[str, Any]) -> bool:
|
||||
for container in _panel_user_connection_containers(panel_user):
|
||||
for key in (*_PANEL_LAST_CONNECTED_KEYS, "firstConnectedAt", "first_connected_at"):
|
||||
if _coerce_panel_datetime(container.get(key)):
|
||||
return True
|
||||
for key in ("lastConnectedNodeUuid", "last_connected_node_uuid"):
|
||||
if str(container.get(key) or "").strip():
|
||||
return True
|
||||
for container in [panel_user, *_panel_nested_dicts(panel_user, _PANEL_TRAFFIC_OBJECT_KEYS)]:
|
||||
for key in _PANEL_CONNECTION_MARKER_OBJECT_KEYS:
|
||||
marker = container.get(key)
|
||||
if isinstance(marker, dict) and any(
|
||||
str(value or "").strip() for value in marker.values()
|
||||
):
|
||||
return True
|
||||
if marker and not isinstance(marker, dict):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _panel_user_connection_activity(panel_user_data: Any) -> Dict[str, Any]:
|
||||
panel_user = _panel_user_payload(panel_user_data)
|
||||
last_connected_at = _panel_user_last_connected_at(panel_user)
|
||||
if not panel_user:
|
||||
return {"status": "unknown", "last_connected_at": None}
|
||||
if last_connected_at or _panel_user_positive_traffic_bytes(panel_user):
|
||||
return {"status": "connected", "last_connected_at": last_connected_at}
|
||||
if _panel_user_has_connected_marker_value(panel_user):
|
||||
return {"status": "connected", "last_connected_at": last_connected_at}
|
||||
if _panel_user_has_connection_marker(panel_user):
|
||||
return {"status": "never", "last_connected_at": None}
|
||||
return {"status": "unknown", "last_connected_at": None}
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> Dict[str, Any]:
|
||||
return {
|
||||
"user_id": int(user.user_id),
|
||||
@@ -94,6 +263,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,
|
||||
@@ -117,9 +289,13 @@ def _serialize_subscription(sub: Subscription) -> Dict[str, Any]:
|
||||
"regular_unlimited_override": regular_unlimited_override,
|
||||
"premium_unlimited_override": premium_unlimited_override,
|
||||
"premium_is_limited": bool(sub.premium_is_limited),
|
||||
"hwid_device_limit": getattr(sub, "hwid_device_limit", None),
|
||||
"extra_hwid_devices": int(getattr(sub, "extra_hwid_devices", 0) or 0),
|
||||
"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),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
from .auth import _require_admin_user_id
|
||||
from .common import _ok
|
||||
from bot.services.config_health_service import collect_config_alerts
|
||||
|
||||
|
||||
async def admin_health_route(request: web.Request) -> web.Response:
|
||||
_require_admin_user_id(request)
|
||||
refresh = str(request.query.get("refresh", "")).strip().lower() in {"1", "true", "yes"}
|
||||
alerts = await collect_config_alerts(request, refresh=refresh)
|
||||
return _ok(
|
||||
{
|
||||
"alerts": alerts,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
@@ -6,6 +6,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router = app.router
|
||||
router.add_get("/api/admin/me", admin_me_route)
|
||||
router.add_get("/api/admin/stats", admin_stats_route)
|
||||
router.add_get("/api/admin/health", admin_health_route)
|
||||
|
||||
router.add_get("/api/admin/users", admin_users_list_route)
|
||||
router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route)
|
||||
@@ -30,6 +31,10 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
"/api/admin/users/{user_id:-?\\d+}/regular-traffic-override",
|
||||
admin_user_regular_traffic_override_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/hwid-device-limit",
|
||||
admin_user_hwid_device_limit_route,
|
||||
)
|
||||
router.add_post(
|
||||
"/api/admin/users/{user_id:-?\\d+}/traffic-grant",
|
||||
admin_user_traffic_grant_route,
|
||||
@@ -57,6 +62,7 @@ def setup_admin_routes(app: web.Application) -> None:
|
||||
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
|
||||
router.add_get("/api/admin/support/stats", admin_support_stats_route)
|
||||
|
||||
router.add_get("/api/admin/broadcast/audience-counts", admin_broadcast_audience_counts_route)
|
||||
router.add_post("/api/admin/broadcast", admin_broadcast_route)
|
||||
router.add_post("/api/admin/sync", admin_sync_route)
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[5] / "data" / "webap
|
||||
WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
WEBAPP_FAVICON_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-logo" / "favicons"
|
||||
WEBAPP_FAVICON_PATH = "/webapp-favicon"
|
||||
WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[5] / "data" / "webapp-emoji"
|
||||
WEBAPP_FAVICON_SIZES = (16, 32, 48, 180, 192, 512)
|
||||
WEBAPP_LOGO_UPLOAD_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
@@ -136,10 +135,6 @@ def _favicon_digest(url: str) -> Optional[str]:
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
keep_logos = {
|
||||
filename
|
||||
@@ -156,15 +151,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
]
|
||||
if digest
|
||||
}
|
||||
keep_emoji_prefixes = set()
|
||||
if (
|
||||
getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False)
|
||||
and str(getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "") or "").strip()
|
||||
== "noto-color-animated"
|
||||
):
|
||||
codepoints = _emoji_to_codepoints(getattr(settings, "WEBAPP_LOGO_EMOJI", ""))
|
||||
if codepoints:
|
||||
keep_emoji_prefixes.add(f"{codepoints}.512.")
|
||||
|
||||
for path in WEBAPP_UPLOADED_LOGO_DIR.glob("logo-*"):
|
||||
if path.is_file() and path.name not in keep_logos:
|
||||
@@ -184,15 +170,6 @@ def prune_unused_appearance_assets(settings: Settings) -> None:
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp favicon set %s", path, exc_info=True)
|
||||
|
||||
for path in WEBAPP_EMOJI_CACHE_DIR.glob("*.512.*"):
|
||||
if path.is_file() and not any(
|
||||
path.name.startswith(prefix) for prefix in keep_emoji_prefixes
|
||||
):
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
logger.warning("Failed to remove unused webapp emoji asset %s", path, exc_info=True)
|
||||
|
||||
|
||||
async def _persist_appearance_upload(
|
||||
request: web.Request,
|
||||
@@ -389,7 +366,6 @@ async def admin_appearance_logo_upload_route(request: web.Request) -> web.Respon
|
||||
request,
|
||||
{
|
||||
"WEBAPP_LOGO_URL": logo_url,
|
||||
"WEBAPP_LOGO_USE_EMOJI": False,
|
||||
**(
|
||||
{"WEBAPP_LOGO_FAVICON_URL": favicon_payload["favicon_url"]}
|
||||
if favicon_payload.get("favicon_url")
|
||||
|
||||
@@ -5,6 +5,7 @@ from .common import (
|
||||
_build_admin_webapp_referral_link,
|
||||
_error,
|
||||
_ok,
|
||||
_panel_user_connection_activity,
|
||||
_premium_traffic_list_payload,
|
||||
_read_json,
|
||||
_serialize_payment,
|
||||
@@ -745,6 +746,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 +783,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)
|
||||
@@ -809,7 +837,13 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
# imports into their VPN client. May be missing if the user has never
|
||||
# been provisioned on the panel.
|
||||
subscription_url: Optional[str] = None
|
||||
panel_uuid = getattr(user, "panel_user_uuid", None)
|
||||
last_vpn_connected_at: Optional[str] = None
|
||||
vpn_connection_status = "unknown"
|
||||
panel_uuid = getattr(user, "panel_user_uuid", None) or getattr(
|
||||
active_sub,
|
||||
"panel_user_uuid",
|
||||
None,
|
||||
)
|
||||
if panel_uuid:
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
panel_service = getattr(subscription_service, "panel_service", None)
|
||||
@@ -818,9 +852,12 @@ async def admin_user_detail_route(request: web.Request) -> web.Response:
|
||||
panel_data = await panel_service.get_user_by_uuid(panel_uuid)
|
||||
if panel_data:
|
||||
subscription_url = panel_data.get("subscriptionUrl") or None
|
||||
vpn_activity = _panel_user_connection_activity(panel_data)
|
||||
vpn_connection_status = str(vpn_activity.get("status") or "unknown")
|
||||
last_vpn_connected_at = vpn_activity.get("last_connected_at")
|
||||
except Exception as exc_panel: # pragma: no cover
|
||||
logger.warning(
|
||||
"Failed to fetch subscriptionUrl for user %s (uuid=%s): %s",
|
||||
"Failed to fetch panel details for user %s (uuid=%s): %s",
|
||||
target_id,
|
||||
panel_uuid,
|
||||
exc_panel,
|
||||
@@ -830,16 +867,20 @@ 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),
|
||||
"subscription_url": subscription_url,
|
||||
"last_vpn_connected_at": last_vpn_connected_at,
|
||||
"vpn_connection_status": vpn_connection_status,
|
||||
"referral": {
|
||||
"code": referral_code,
|
||||
"bot_link": referral_bot_link,
|
||||
@@ -1318,6 +1359,78 @@ async def admin_user_regular_traffic_override_route(request: web.Request) -> web
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_hwid_device_limit_route(request: web.Request) -> web.Response:
|
||||
"""Override the user's base HWID device limit.
|
||||
|
||||
``hwid_device_limit == 0`` means unlimited; ``NULL`` means the tariff/.env
|
||||
default is used. Purchased extra devices remain tracked separately and are
|
||||
added when syncing the effective panel limit.
|
||||
"""
|
||||
actor_id = _require_admin_user_id(request)
|
||||
target_id = int(request.match_info["user_id"])
|
||||
settings: Settings = request.app["settings"]
|
||||
payload = await _read_json(request)
|
||||
|
||||
unlimited = bool(payload.get("unlimited"))
|
||||
use_default = bool(payload.get("use_default") or payload.get("reset_to_default"))
|
||||
limit_raw = payload.get("hwid_device_limit", payload.get("limit"))
|
||||
|
||||
if unlimited:
|
||||
hwid_device_limit: Optional[int] = 0
|
||||
elif use_default or limit_raw is None or limit_raw == "":
|
||||
hwid_device_limit = None
|
||||
else:
|
||||
try:
|
||||
hwid_device_limit = int(limit_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _error(
|
||||
400,
|
||||
"invalid_hwid_device_limit",
|
||||
"hwid_device_limit must be a non-negative integer",
|
||||
)
|
||||
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||
return _error(
|
||||
400,
|
||||
"invalid_hwid_device_limit",
|
||||
"hwid_device_limit must be an integer from 0 to 1000000",
|
||||
)
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
active = await subscription_dal.get_active_subscription_by_user_id(session, target_id)
|
||||
if not active:
|
||||
return _error(404, "no_active_subscription")
|
||||
|
||||
active.hwid_device_limit = hwid_device_limit
|
||||
|
||||
effective_limit = None
|
||||
if subscription_service is not None:
|
||||
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||
session, target_id
|
||||
)
|
||||
|
||||
await message_log_dal.create_message_log(
|
||||
session,
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_hwid_device_limit_webapp",
|
||||
"content": (
|
||||
f"hwid_device_limit={hwid_device_limit!r} "
|
||||
f"effective_hwid_device_limit={effective_limit!r}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(active)
|
||||
|
||||
await _invalidate_after_admin_user_mutation(settings, target_id)
|
||||
return _ok({"subscription": _serialize_subscription(active)})
|
||||
|
||||
|
||||
async def admin_user_traffic_grant_route(request: web.Request) -> web.Response:
|
||||
"""Credit regular or premium traffic to a user without a payment.
|
||||
|
||||
@@ -1414,6 +1527,8 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
return _error(400, "invalid_days")
|
||||
if days <= 0:
|
||||
return _error(400, "invalid_days")
|
||||
extend_hwid_devices = payload.get("extend_hwid_devices")
|
||||
extend_hwid_devices = True if extend_hwid_devices is None else bool(extend_hwid_devices)
|
||||
|
||||
subscription_service = request.app.get("subscription_service")
|
||||
if subscription_service is None:
|
||||
@@ -1426,6 +1541,7 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
target_id,
|
||||
days,
|
||||
"admin_extend_subscription_webapp",
|
||||
extend_hwid_devices=extend_hwid_devices,
|
||||
)
|
||||
if not new_end:
|
||||
await session.rollback()
|
||||
@@ -1436,7 +1552,10 @@ async def admin_user_extend_route(request: web.Request) -> web.Response:
|
||||
{
|
||||
"user_id": actor_id,
|
||||
"event_type": "admin_extend_subscription_webapp",
|
||||
"content": f"+{days}d -> {new_end.isoformat()}",
|
||||
"content": (
|
||||
f"+{days}d -> {new_end.isoformat()} "
|
||||
f"(hwid={'yes' if extend_hwid_devices else 'no'})"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_id,
|
||||
},
|
||||
|
||||
@@ -13,9 +13,6 @@ WEBAPP_APPEARANCE_SETTING_KEYS = frozenset(
|
||||
{
|
||||
"WEBAPP_TITLE",
|
||||
"WEBAPP_LOGO_URL",
|
||||
"WEBAPP_LOGO_USE_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"WEBAPP_FAVICON_URL",
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"WEBAPP_LOGO_FAVICON_URL",
|
||||
|
||||
@@ -65,7 +65,6 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."
|
||||
),
|
||||
SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"),
|
||||
SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"),
|
||||
SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"),
|
||||
SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"),
|
||||
SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"),
|
||||
@@ -77,14 +76,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 +106,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",
|
||||
@@ -139,27 +180,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField(
|
||||
"WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"
|
||||
),
|
||||
SettingField("WEBAPP_LOGO_USE_EMOJI", "bool", "appearance", "Использовать эмоджи-логотип"),
|
||||
SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"),
|
||||
SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"),
|
||||
SettingField(
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"string",
|
||||
"appearance",
|
||||
"Шрифт эмоджи-логотипа",
|
||||
"Выберите шрифт для отображения эмодзи-логотипа",
|
||||
choices=(
|
||||
("system", "Системный (по умолчанию)"),
|
||||
("noto-color", "Noto Color Emoji"),
|
||||
("noto-color-animated", "Noto Color Emoji Animated"),
|
||||
("noto-emoji", "Noto Emoji"),
|
||||
("twemoji", "Twitter Emoji"),
|
||||
("openmoji", "OpenMoji"),
|
||||
("apple", "Apple Color Emoji (local)"),
|
||||
("segoe", "Segoe UI Emoji (local)"),
|
||||
("noto-local", "Noto Emoji (local)"),
|
||||
),
|
||||
),
|
||||
SettingField(
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"bool",
|
||||
@@ -327,6 +348,16 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"Английская версия текста на этапе оплаты.",
|
||||
subsection="checkout",
|
||||
),
|
||||
SettingField(
|
||||
"PAYMENT_REQUEST_TIMEOUT_SECONDS",
|
||||
"float",
|
||||
"payments",
|
||||
"Таймаут запроса к провайдеру",
|
||||
"Максимальное общее время одного API-запроса к платёжному провайдеру, в секундах.",
|
||||
optional=False,
|
||||
min=1,
|
||||
subsection="checkout",
|
||||
),
|
||||
# ─── Payment providers (toggles) ───────────────────────────────
|
||||
# Common
|
||||
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="common"),
|
||||
@@ -348,7 +379,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
"string",
|
||||
"payments",
|
||||
"Порядок методов оплаты",
|
||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket",
|
||||
"Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla",
|
||||
subsection="common",
|
||||
),
|
||||
# ─── Trial ─────────────────────────────────────────────────────
|
||||
@@ -386,6 +417,18 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_WITHOUT_TELEGRAM_ENABLED",
|
||||
"bool",
|
||||
"pricing",
|
||||
"Триал без Telegram",
|
||||
(
|
||||
"Если выключено, email-only пользователю нужно привязать Telegram для "
|
||||
"активации триала. Disposable email домены всегда требуют Telegram."
|
||||
),
|
||||
optional=False,
|
||||
subsection="trial",
|
||||
),
|
||||
SettingField(
|
||||
"TRIAL_SQUAD_UUIDS",
|
||||
"string",
|
||||
@@ -396,12 +439,82 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
),
|
||||
# ─── Referral program ──────────────────────────────────────────
|
||||
SettingField(
|
||||
"REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"
|
||||
"REFERRAL_ONE_BONUS_PER_REFEREE",
|
||||
"bool",
|
||||
"pricing",
|
||||
"Один бонус на приглашённого",
|
||||
subsection="referral",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0
|
||||
"REFERRAL_WELCOME_BONUS_DAYS",
|
||||
"int",
|
||||
"pricing",
|
||||
"Приветственный бонус (дней)",
|
||||
min=0,
|
||||
subsection="referral",
|
||||
),
|
||||
SettingField(
|
||||
"REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
|
||||
"bool",
|
||||
"pricing",
|
||||
"Приветственный бонус без Telegram",
|
||||
(
|
||||
"Если выключено, email-only пользователю нужно привязать Telegram для получения "
|
||||
"реферального приветственного бонуса. Disposable email домены всегда требуют Telegram."
|
||||
),
|
||||
subsection="referral",
|
||||
),
|
||||
SettingField(
|
||||
"LEGACY_REFS",
|
||||
"bool",
|
||||
"pricing",
|
||||
"Поддержка старых ref-ссылок",
|
||||
subsection="referral",
|
||||
),
|
||||
SettingField(
|
||||
"DISPOSABLE_EMAIL_DOMAINS",
|
||||
"text",
|
||||
"pricing",
|
||||
"Disposable email домены",
|
||||
(
|
||||
"Домены по одному на строку или через запятую. Пользователи без Telegram с такими "
|
||||
"email не смогут получить trial или реферальный приветственный бонус."
|
||||
),
|
||||
placeholder="mailinator.com\ntemp-mail.org\nyopmail.com",
|
||||
subsection="referral",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED",
|
||||
"bool",
|
||||
"migrations",
|
||||
"Старые ref-ссылки Remnashop",
|
||||
"Принимать импортированные ref-коды Remnashop вместе с текущими кодами пользователей.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED",
|
||||
"bool",
|
||||
"migrations",
|
||||
"Старые промокоды Remnashop",
|
||||
"Пробовать точное совпадение промокода перед обычной uppercase-нормализацией.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_IMPORTED_AT",
|
||||
"string",
|
||||
"migrations",
|
||||
"Последний импорт Remnashop",
|
||||
"Заполняется скриптом импорта. Можно очистить, если отметка больше не нужна.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField(
|
||||
"MIGRATION_REMNASHOP_NOTES",
|
||||
"text",
|
||||
"migrations",
|
||||
"Заметки по миграции Remnashop",
|
||||
"Внутренние заметки оператора по перенесенному инстансу.",
|
||||
subsection="Remnashop",
|
||||
),
|
||||
SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"),
|
||||
# ─── Notifications ─────────────────────────────────────────────
|
||||
SettingField(
|
||||
"SUBSCRIPTION_NOTIFICATIONS_ENABLED",
|
||||
@@ -589,15 +702,130 @@ SETTINGS_MANIFEST: List[SettingField] = [
|
||||
SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"),
|
||||
SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"),
|
||||
# ─── System ────────────────────────────────────────────────────
|
||||
SettingField(
|
||||
"TELEGRAM_DROP_NON_PRIVATE_UPDATES",
|
||||
"bool",
|
||||
"system",
|
||||
"Drop non-private Telegram updates",
|
||||
"Drops group/channel messages and callbacks before DB-backed middleware runs.",
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_ANTIFLOOD_ENABLED",
|
||||
"bool",
|
||||
"system",
|
||||
"Telegram anti-flood enabled",
|
||||
"Enables soft per-user limits for extreme Telegram update floods.",
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_ANTIFLOOD_WINDOW_SECONDS",
|
||||
"int",
|
||||
"system",
|
||||
"Anti-flood window",
|
||||
"Rolling window, in seconds, used by all Telegram anti-flood buckets.",
|
||||
min=1,
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW",
|
||||
"int",
|
||||
"system",
|
||||
"All updates limit",
|
||||
"Maximum total Telegram updates from one actor during the window. 0 disables this bucket.",
|
||||
min=0,
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW",
|
||||
"int",
|
||||
"system",
|
||||
"Messages limit",
|
||||
"Maximum message updates from one actor during the window. 0 disables this bucket.",
|
||||
min=0,
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW",
|
||||
"int",
|
||||
"system",
|
||||
"Button callbacks limit",
|
||||
"Maximum callback-query updates from one actor during the window. 0 disables this bucket.",
|
||||
min=0,
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW",
|
||||
"int",
|
||||
"system",
|
||||
"Inline queries limit",
|
||||
"Maximum inline-query updates from one actor during the window. 0 disables this bucket.",
|
||||
min=0,
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW",
|
||||
"int",
|
||||
"system",
|
||||
"/start limit",
|
||||
"Maximum /start messages from one actor during the window. 0 disables this bucket.",
|
||||
min=0,
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW",
|
||||
"int",
|
||||
"system",
|
||||
"Expensive callbacks limit",
|
||||
(
|
||||
"Maximum payment, trial, promo and account-changing callbacks from one actor "
|
||||
"during the window. 0 disables this bucket."
|
||||
),
|
||||
min=0,
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_ACTION_COOLDOWN_ENABLED",
|
||||
"bool",
|
||||
"system",
|
||||
"Action cooldowns enabled",
|
||||
"Deduplicates repeated payment and trial button presses from the same user.",
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS",
|
||||
"int",
|
||||
"system",
|
||||
"Payment callback cooldown",
|
||||
(
|
||||
"Seconds to suppress an exact repeated payment callback from the same user. "
|
||||
"0 disables this cooldown."
|
||||
),
|
||||
min=0,
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS",
|
||||
"int",
|
||||
"system",
|
||||
"Trial callback cooldown",
|
||||
(
|
||||
"Seconds to suppress an exact repeated trial activation callback from the same user. "
|
||||
"0 disables this cooldown."
|
||||
),
|
||||
min=0,
|
||||
subsection="telegram_antiflood",
|
||||
),
|
||||
SettingField(
|
||||
"TELEMETRY_ENABLED",
|
||||
"bool",
|
||||
"system",
|
||||
"Анонимная статистика установки",
|
||||
"Раз в сутки отправляет обезличенный сигнал: версия, ОС, локаль и число "
|
||||
"пользователей в виде диапазона. Без персональных данных, токенов и "
|
||||
"доменов. Помогает понять число активных установок и какие версии "
|
||||
"используются. Можно отключить здесь без перезапуска.",
|
||||
"Раз в сутки отправляет обезличенный сигнал: версия, маркер образа "
|
||||
"official/custom, ОС, локаль и число пользователей в виде диапазона. Без персональных "
|
||||
"данных, токенов и доменов. Помогает понять число активных установок, какие "
|
||||
"версии используются и долю изменённых сборок. Можно отключить здесь без "
|
||||
"перезапуска.",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -734,6 +962,7 @@ def manifest_payload() -> List[dict]:
|
||||
"devices": 10,
|
||||
"subscription_guides": 10,
|
||||
"system": 12,
|
||||
"migrations": 13,
|
||||
}
|
||||
exclusive_map = {
|
||||
key: opposite
|
||||
|
||||
@@ -8,11 +8,18 @@
|
||||
<style nonce="__NONCE__">
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
--accent: #14b86f;
|
||||
--accent-contrast: #03120b;
|
||||
--bg: #0b1017;
|
||||
--panel-3: #344052;
|
||||
--border: #2d3847;
|
||||
--text: #f7fafc;
|
||||
--muted: #aeb8c5;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
background: #0b1017;
|
||||
color: #f7fafc;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -39,7 +46,7 @@
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #aeb8c5;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
@@ -57,8 +64,8 @@
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: #14b86f;
|
||||
color: #03120b;
|
||||
background: var(--accent);
|
||||
color: var(--accent-contrast);
|
||||
padding: 0 18px;
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
@@ -68,15 +75,15 @@
|
||||
}
|
||||
|
||||
.button.secondary {
|
||||
border-color: #2d3847;
|
||||
border-color: var(--border);
|
||||
background: transparent;
|
||||
color: #f7fafc;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.button[aria-disabled="true"] {
|
||||
pointer-events: none;
|
||||
background: #344052;
|
||||
color: #aeb8c5;
|
||||
background: var(--panel-3);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon-precomposed.png"
|
||||
/>
|
||||
<title>/minishop</title>
|
||||
<title>Subscription</title>
|
||||
<link rel="stylesheet" href="/subscription_webapp.css" />
|
||||
<style>
|
||||
.app-boot-fallback {
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
.theme-key-ascii .admin-toolbar-card,
|
||||
.theme-key-ascii .admin-table-card,
|
||||
.theme-key-ascii .admin-panel-dash-card,
|
||||
.theme-key-ascii .admin-config-alerts,
|
||||
.theme-key-ascii .admin-select-trigger,
|
||||
.theme-key-ascii .admin-select-content,
|
||||
.theme-key-ascii .admin-cn-card[data-slot="card"],
|
||||
@@ -199,7 +200,8 @@
|
||||
.theme-key-ascii .admin-tabs-trigger,
|
||||
.theme-key-ascii .admin-revenue-period-btn,
|
||||
.theme-key-ascii .admin-mobile-toggle,
|
||||
.theme-key-ascii .admin-nav-item {
|
||||
.theme-key-ascii .admin-nav-item,
|
||||
.theme-key-ascii .admin-config-alert-link {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
@@ -216,7 +218,8 @@
|
||||
.theme-key-ascii .admin-nav-item:hover,
|
||||
.theme-key-ascii .admin-tabs-trigger:hover,
|
||||
.theme-key-ascii .admin-revenue-period-btn:hover,
|
||||
.theme-key-ascii .bottom-nav button:hover {
|
||||
.theme-key-ascii .bottom-nav button:hover,
|
||||
.theme-key-ascii .admin-config-alert-link:hover {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
@@ -318,6 +321,133 @@
|
||||
box-shadow: inset 0 0 0 1px #ffffff;
|
||||
}
|
||||
|
||||
/* ---------- Admin controls: range sliders and sortable rows ---------- */
|
||||
|
||||
.theme-key-ascii .ui-range-input {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input::before {
|
||||
height: 8px;
|
||||
border: 1px solid #ffffff;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input__range {
|
||||
height: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input__thumb {
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-range-input__thumb:hover,
|
||||
.theme-key-ascii .ui-range-input__thumb:focus-visible {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
box-shadow: 0 0 0 1px #000000;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable {
|
||||
--sortable-drop-line: #ffffff;
|
||||
--sortable-drop-soft: rgba(255, 255, 255, 0.08);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-item.is-dragging {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-item.is-drop-target {
|
||||
outline: 1px dashed #ffffff;
|
||||
outline-offset: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-item.is-drop-target::before {
|
||||
top: -5px;
|
||||
height: 1px;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-handle {
|
||||
align-self: center;
|
||||
height: 28px;
|
||||
border: 1px solid #ffffff;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .ui-sortable-handle:hover,
|
||||
.theme-key-ascii .ui-sortable-handle:focus-visible,
|
||||
.theme-key-ascii .ui-sortable-handle:active {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* ---------- Admin health config alerts ---------- */
|
||||
|
||||
.theme-key-ascii .admin-config-alerts {
|
||||
position: relative;
|
||||
padding-left: 18px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alerts::before {
|
||||
content: "!";
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
left: 7px;
|
||||
color: #ffffff;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alerts-error {
|
||||
border-color: #ff5555;
|
||||
color: #ffaaaa;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alerts-error::before {
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-dot {
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-dot::before {
|
||||
content: ">";
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-error .admin-config-alert-dot {
|
||||
background: transparent;
|
||||
color: #ff5555;
|
||||
}
|
||||
|
||||
.theme-key-ascii .admin-config-alert-link {
|
||||
padding: 1px 7px;
|
||||
font-family: var(--font-mono);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ---------- New webapp surfaces: support, purchase info, password login ---------- */
|
||||
|
||||
.theme-key-ascii .trial-offer-card,
|
||||
@@ -1104,6 +1234,7 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
.admin-cn-card-skeleton--tall,
|
||||
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
|
||||
.admin-tabs-trigger, .admin-tabs-list,
|
||||
.ui-range-input__thumb, .ui-sortable-item, .ui-sortable-handle,
|
||||
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
|
||||
.admin-header, .admin-sidebar, .admin-sidebar-brand,
|
||||
.admin-dialog,
|
||||
@@ -1243,3 +1374,51 @@ body:has(.theme-key-ascii) .install-platform-item[data-selected] svg {
|
||||
.theme-key-ascii table tbody tr:hover td {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* Newer webapp surfaces: telegram banner, traffic/referral
|
||||
* dropdowns, login language picker. Flatten the accent pills,
|
||||
* rounded badges and colored gradients these ship with so they
|
||||
* read as plain console boxes.
|
||||
* ============================================================ */
|
||||
|
||||
/* Telegram notifications banner: the .card chrome is already
|
||||
* flattened above; only the rounded, color-tinted icon badge needs
|
||||
* squaring off (the Send glyph itself is whitened by the global rule). */
|
||||
.theme-key-ascii .telegram-notifications-icon {
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 0;
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Premium-server / referral-tariff dropdown help glyph: drop the
|
||||
* pill background in every state (the accent maps to white here,
|
||||
* which would otherwise paint a white blob behind the icon). */
|
||||
.theme-key-ascii .premium-server-help-icon,
|
||||
.theme-key-ascii .premium-server-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-ascii .premium-server-dropdown[open] .premium-server-help-icon,
|
||||
.theme-key-ascii .referral-tariff-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-ascii .referral-tariff-dropdown[open] .premium-server-help-icon {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* The check on the selected language sits on a solid white row, so a
|
||||
* white glyph would vanish — invert it to black to keep it readable. */
|
||||
.theme-key-ascii .language-select-item[data-selected] .language-select-item-check {
|
||||
color: #000000 !important;
|
||||
stroke: #000000 !important;
|
||||
}
|
||||
|
||||
/* Login-screen language trigger: square the rounded chip. */
|
||||
.theme-key-ascii .auth-language-trigger {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Render flag emoji as monochrome glyphs to stay in the console palette. */
|
||||
.theme-key-ascii .emoji-flag {
|
||||
filter: grayscale(1) contrast(1.05);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 4,
|
||||
"assets_version": 7,
|
||||
"tokens": {
|
||||
"color_scheme": "dark",
|
||||
"style_preset": "ascii"
|
||||
|
||||
@@ -204,3 +204,77 @@ body:has(.theme-key-light) .install-platform-item[data-selected] {
|
||||
.theme-key-light .install-loading .ui-spinner {
|
||||
color: color-mix(in srgb, var(--accent) 55%, #000000);
|
||||
}
|
||||
|
||||
/* Admin controls: range sliders and sortable rows */
|
||||
|
||||
.theme-key-light .ui-range-input::before {
|
||||
background: rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-range-input__range {
|
||||
background: color-mix(in srgb, var(--accent) 70%, #0f172a);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-range-input__thumb {
|
||||
border-color: color-mix(in srgb, var(--accent) 68%, #0f172a);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-range-input__thumb:focus-visible {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-handle {
|
||||
border-radius: 6px;
|
||||
color: color-mix(in srgb, var(--admin-muted) 82%, var(--admin-text));
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-handle:hover,
|
||||
.theme-key-light .ui-sortable-handle:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.055);
|
||||
color: color-mix(in srgb, var(--accent) 58%, #0f172a);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable {
|
||||
--sortable-drop-line: color-mix(in srgb, var(--accent) 64%, #0f172a);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-item.is-drop-target {
|
||||
background: color-mix(in srgb, var(--accent) 8%, #ffffff);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--accent) 26%, transparent),
|
||||
0 10px 22px color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
}
|
||||
|
||||
.theme-key-light .ui-sortable-item.is-drop-target::before {
|
||||
background: var(--sortable-drop-line);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
}
|
||||
|
||||
/* Admin health config alerts */
|
||||
|
||||
.theme-key-light .admin-config-alerts {
|
||||
border-color: color-mix(in srgb, var(--warning) 38%, var(--admin-border));
|
||||
background: color-mix(in srgb, var(--warning) 9%, #ffffff);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.theme-key-light .admin-config-alerts-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 38%, var(--admin-border));
|
||||
background: color-mix(in srgb, var(--danger) 8%, #ffffff);
|
||||
}
|
||||
|
||||
.theme-key-light .admin-config-alert-link {
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.theme-key-light .admin-config-alert-link:hover {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Telegram notifications banner: keep the warm warning tint but swap the
|
||||
* dark-theme inset bevel for the soft drop shadow the other light cards use. */
|
||||
.theme-key-light .telegram-notifications-card {
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": true,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 3,
|
||||
"assets_version": 6,
|
||||
"tokens": {
|
||||
"color_scheme": "light"
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
.theme-key-windows95 svg.lucide-file-text,
|
||||
.theme-key-windows95 svg.lucide-gift,
|
||||
.theme-key-windows95 svg.lucide-globe-2,
|
||||
.theme-key-windows95 svg.lucide-grip-vertical,
|
||||
.theme-key-windows95 svg.lucide-home,
|
||||
.theme-key-windows95 svg.lucide-house,
|
||||
.theme-key-windows95 svg.lucide-info,
|
||||
@@ -232,6 +233,10 @@
|
||||
--win95-button-icon: var(--win95-icon-globe);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-grip-vertical {
|
||||
--win95-button-icon: var(--win95-icon-sliders);
|
||||
}
|
||||
|
||||
.theme-key-windows95 svg.lucide-file-text {
|
||||
--win95-button-icon: var(--win95-icon-file-text);
|
||||
}
|
||||
@@ -375,6 +380,7 @@
|
||||
svg.lucide-file-text,
|
||||
svg.lucide-gift,
|
||||
svg.lucide-globe-2,
|
||||
svg.lucide-grip-vertical,
|
||||
svg.lucide-home,
|
||||
svg.lucide-house,
|
||||
svg.lucide-info,
|
||||
@@ -1171,6 +1177,149 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
/* Admin controls: range sliders and sortable rows */
|
||||
|
||||
.theme-key-windows95 .ui-range-input {
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input::before {
|
||||
height: 8px;
|
||||
border: 2px solid;
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
border-radius: 0 !important;
|
||||
background: #ffffff;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__range {
|
||||
height: 8px;
|
||||
border-radius: 0 !important;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__thumb {
|
||||
width: 14px;
|
||||
height: 20px;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0 !important;
|
||||
background: #c0c0c0;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__thumb:hover,
|
||||
.theme-key-windows95 .ui-range-input__thumb:focus-visible {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-range-input__thumb[data-active] {
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-item.is-drop-target {
|
||||
outline: 1px dotted #000000;
|
||||
outline-offset: 3px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface));
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-item.is-drop-target::before {
|
||||
top: -7px;
|
||||
height: 2px;
|
||||
border-radius: 0;
|
||||
background: #000080;
|
||||
box-shadow:
|
||||
0 1px 0 #ffffff,
|
||||
0 -1px 0 #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-handle {
|
||||
align-self: center;
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-handle:hover,
|
||||
.theme-key-windows95 .ui-sortable-handle:focus-visible {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .ui-sortable-handle:active {
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
/* Admin health config alerts */
|
||||
|
||||
.theme-key-windows95 .admin-config-alerts {
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #ffffcc;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alerts-error {
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
background: #f7d6d6;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-dot {
|
||||
border-radius: 0;
|
||||
background: #808000;
|
||||
box-shadow:
|
||||
1px 1px 0 #ffffff,
|
||||
-1px -1px 0 #404040;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-error .admin-config-alert-dot {
|
||||
background: #800000;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-link {
|
||||
border: 2px solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0;
|
||||
background: #c0c0c0;
|
||||
color: #000000;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-link:hover {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .admin-config-alert-link:active {
|
||||
border-color: #404040 #ffffff #ffffff #404040;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #808080,
|
||||
inset -1px -1px 0 #dfdfdf;
|
||||
}
|
||||
|
||||
.theme-key-windows95 input::placeholder,
|
||||
.theme-key-windows95 textarea::placeholder,
|
||||
.theme-key-windows95 .input::placeholder,
|
||||
@@ -1352,3 +1501,64 @@ body:has(.theme-key-windows95) .install-platform-item[data-selected] {
|
||||
.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited {
|
||||
color: #800080;
|
||||
}
|
||||
|
||||
/* ---------- Newer webapp surfaces: telegram banner, traffic /
|
||||
* referral dropdowns, login language picker ---------- */
|
||||
|
||||
/* Telegram notifications banner: the Card chrome is already beveled by
|
||||
* the shared .card rule; give the icon badge a raised chip look instead
|
||||
* of the rounded, color-tinted default (the Send glyph maps to send.png). */
|
||||
.theme-key-windows95 .telegram-notifications-icon {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
/* Standalone referral-tariff dropdown and bonus rows: bevel them like the
|
||||
* rest of the surfaces so they don't read as flat 1px boxes. */
|
||||
.theme-key-windows95 .referral-tariff-dropdown,
|
||||
.theme-key-windows95 .referral-bonus-row {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff #404040 #404040 #ffffff;
|
||||
border-radius: 0;
|
||||
background: var(--panel);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 #dfdfdf,
|
||||
inset -1px -1px 0 #808080;
|
||||
}
|
||||
|
||||
.theme-key-windows95 .referral-bonus-row-nested {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
/* Premium-server / referral help glyph: drop the rounded accent pill so it
|
||||
* sits inline as a plain stroked question mark. */
|
||||
.theme-key-windows95 .premium-server-help-icon,
|
||||
.theme-key-windows95 .premium-server-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-windows95 .premium-server-dropdown[open] .premium-server-help-icon,
|
||||
.theme-key-windows95 .referral-tariff-dropdown summary:hover .premium-server-help-icon,
|
||||
.theme-key-windows95 .referral-tariff-dropdown[open] .premium-server-help-icon {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* The selected language row turns navy; its check maps to a dark bitmap,
|
||||
* so invert it to white to keep it visible. */
|
||||
.theme-key-windows95 .language-select-item[data-highlighted] .language-select-item-check,
|
||||
.theme-key-windows95 .language-select-item[data-selected] .language-select-item-check {
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
|
||||
/* Login-screen language trigger: square the rounded chip. */
|
||||
.theme-key-windows95 .auth-language-trigger {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"use_primary_accent": false,
|
||||
"use_in_admin": true,
|
||||
"css_file": "style.css",
|
||||
"assets_version": 11,
|
||||
"assets_version": 14,
|
||||
"tokens": {
|
||||
"color_scheme": "light",
|
||||
"style_preset": "win95"
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
from aiohttp import web
|
||||
from aiohttp.web_log import AccessLogger, KeyMethod
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.payment_providers import iter_provider_specs, iter_service_keys
|
||||
from bot.utils.request_security import request_client_ip
|
||||
from config.settings import Settings
|
||||
|
||||
|
||||
@@ -18,6 +22,39 @@ class SecureSimpleRequestHandler(SimpleRequestHandler):
|
||||
return hmac.compare_digest(telegram_secret_token, self.secret_token)
|
||||
|
||||
|
||||
class TrustedProxyAccessLogger(AccessLogger):
|
||||
"""Aiohttp access logger that respects trusted X-Forwarded-For headers."""
|
||||
|
||||
def compile_format(self, log_format):
|
||||
methods = []
|
||||
for atom in self.FORMAT_RE.findall(log_format):
|
||||
if atom[1] == "":
|
||||
format_key = self.LOG_FORMAT_MAP[atom[0]]
|
||||
method = getattr(type(self), f"_format_{atom[0]}", None)
|
||||
if method is None:
|
||||
method = getattr(AccessLogger, f"_format_{atom[0]}")
|
||||
methods.append(KeyMethod(format_key, method))
|
||||
else:
|
||||
format_key = (self.LOG_FORMAT_MAP[atom[2]], atom[1])
|
||||
method = getattr(type(self), f"_format_{atom[2]}", None)
|
||||
if method is None:
|
||||
method = getattr(AccessLogger, f"_format_{atom[2]}")
|
||||
methods.append(KeyMethod(format_key, functools.partial(method, atom[1])))
|
||||
|
||||
compiled = self.FORMAT_RE.sub(r"%s", log_format)
|
||||
compiled = self.CLEANUP_RE.sub(r"%\1", compiled)
|
||||
return compiled, methods
|
||||
|
||||
@staticmethod
|
||||
def _format_a(request, response, time):
|
||||
if request is None:
|
||||
return "-"
|
||||
settings = request.app.get("settings") if hasattr(request, "app") else None
|
||||
trusted_proxies = getattr(settings, "trusted_proxies", None)
|
||||
client_ip = request_client_ip(request, trusted_proxies=trusted_proxies)
|
||||
return client_ip or "-"
|
||||
|
||||
|
||||
def _inject_shared_instances(
|
||||
app: web.Application,
|
||||
dp: Dispatcher,
|
||||
@@ -48,6 +85,8 @@ async def build_and_start_web_app(
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
async_session_factory: sessionmaker,
|
||||
*,
|
||||
after_webhooks_started: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
):
|
||||
app = web.Application()
|
||||
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
|
||||
@@ -110,7 +149,7 @@ async def build_and_start_web_app(
|
||||
|
||||
runners = []
|
||||
|
||||
webhooks_runner = web.AppRunner(app)
|
||||
webhooks_runner = web.AppRunner(app, access_log_class=TrustedProxyAccessLogger)
|
||||
await webhooks_runner.setup()
|
||||
runners.append(webhooks_runner)
|
||||
site = web.TCPSite(
|
||||
@@ -123,6 +162,8 @@ async def build_and_start_web_app(
|
||||
logging.info(
|
||||
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
|
||||
)
|
||||
if after_webhooks_started is not None:
|
||||
await after_webhooks_started()
|
||||
|
||||
if settings.WEBAPP_ENABLED:
|
||||
from bot.app.web.subscription_webapp import create_subscription_webapp_application
|
||||
@@ -133,7 +174,10 @@ async def build_and_start_web_app(
|
||||
settings,
|
||||
async_session_factory,
|
||||
)
|
||||
subscription_runner = web.AppRunner(subscription_app)
|
||||
subscription_runner = web.AppRunner(
|
||||
subscription_app,
|
||||
access_log_class=TrustedProxyAccessLogger,
|
||||
)
|
||||
await subscription_runner.setup()
|
||||
runners.append(subscription_runner)
|
||||
subscription_site = web.TCPSite(
|
||||
|
||||
@@ -42,7 +42,7 @@ from bot.app.web.webapp_auth import (
|
||||
verify_webapp_session_token,
|
||||
)
|
||||
from bot.infra.redis import cache_delete, cache_get_json, cache_set_json, get_redis, redis_key
|
||||
from bot.services.email_auth_service import EmailAuthService, normalize_email
|
||||
from bot.services.email_auth_service import EmailAuthService, is_disposable_email, normalize_email
|
||||
from bot.services.email_templates import render_account_merged
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.referral_service import ReferralService
|
||||
@@ -82,7 +82,6 @@ WEBAPP_DEFAULT_LOGO_PATH = "/webapp-default-logo.webp"
|
||||
WEBAPP_DEFAULT_FAVICON_DIGEST = "19b2a242e5b7bc2d"
|
||||
WEBAPP_DEFAULT_FAVICON_DIR = WEBAPP_DEFAULT_BRAND_DIR / "favicons" / WEBAPP_DEFAULT_FAVICON_DIGEST
|
||||
WEBAPP_DEFAULT_FAVICON_URL = f"{WEBAPP_FAVICON_PATH}/{WEBAPP_DEFAULT_FAVICON_DIGEST}/icon-180.png"
|
||||
WEBAPP_EMOJI_CACHE_DIR = APP_ROOT / "data" / "webapp-emoji"
|
||||
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
|
||||
WEBAPP_I18N_PLACEHOLDER = "<!-- WEBAPP_I18N_SCRIPT -->"
|
||||
WEBAPP_JS_PLACEHOLDER = "<!-- WEBAPP_JS_SCRIPT -->"
|
||||
@@ -92,7 +91,6 @@ DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
|
||||
WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
|
||||
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
|
||||
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
|
||||
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
|
||||
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
|
||||
|
||||
@@ -11,9 +11,20 @@ from .common import _invalidate_webapp_user_caches
|
||||
from .telegram_notifications import _probe_telegram_notifications_for_user_id
|
||||
|
||||
|
||||
def _email_auth_enabled(settings: Settings) -> bool:
|
||||
return bool(getattr(settings, "email_auth_configured", True))
|
||||
|
||||
|
||||
def _email_auth_not_configured_response() -> web.Response:
|
||||
return _json_error(503, "email_auth_not_configured", "Email auth is not configured")
|
||||
|
||||
|
||||
async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
payload = await _read_json(request)
|
||||
email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload)
|
||||
if validation_error:
|
||||
@@ -40,6 +51,10 @@ async def account_email_request_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
@@ -55,7 +70,6 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
email = email_payload.email
|
||||
code = str(email_payload.code or "")
|
||||
email_service: EmailAuthService = request.app["email_auth_service"]
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
merge_notice: Optional[Dict[str, Any]] = None
|
||||
source_panel_uuid: Optional[str] = None
|
||||
@@ -210,6 +224,9 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
|
||||
async def account_password_request_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings: Settings = request.app["settings"]
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
@@ -232,6 +249,10 @@ async def account_password_request_route(request: web.Request) -> web.Response:
|
||||
|
||||
async def account_password_confirm_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
settings = request.app.get("settings")
|
||||
if not _email_auth_enabled(settings):
|
||||
return _email_auth_not_configured_response()
|
||||
|
||||
payload = await _read_json(request)
|
||||
password_payload, validation_error = _validate_model_payload(WebAppSetPasswordPayload, payload)
|
||||
if validation_error:
|
||||
|
||||
@@ -33,7 +33,6 @@ def create_subscription_webapp_application(
|
||||
async def _startup(app_obj: web.Application) -> None:
|
||||
await _ensure_shared_http_session()
|
||||
await _warm_webapp_logo_cache(app_obj)
|
||||
await _warm_webapp_animated_emoji_cache(app_obj)
|
||||
await warm_subscription_guides_config(app_obj)
|
||||
|
||||
async def _shutdown(app_obj: web.Application) -> None:
|
||||
|
||||
@@ -5,6 +5,7 @@ import gzip
|
||||
from config.webapp_themes_config import (
|
||||
default_webapp_theme_asset_file,
|
||||
default_webapp_theme_css_files,
|
||||
effective_webapp_theme_accent,
|
||||
ensure_default_webapp_theme_descriptor_files,
|
||||
public_theme_payload,
|
||||
public_themes_catalog_payload,
|
||||
@@ -17,6 +18,8 @@ _GZIP_BODY_CACHE: Dict[str, bytes] = {}
|
||||
_ASSET_NAME_CACHE: Dict[tuple[str, str], tuple[float, str]] = {}
|
||||
_I18N_PAYLOAD_CACHE: Dict[tuple[int, str, tuple[tuple[str, int, int], ...]], Dict[str, Any]] = {}
|
||||
_ASSET_NAME_CACHE_TTL_SECONDS = 30.0
|
||||
WEBAPP_HTML_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
WEBAPP_LEGACY_ASSET_CACHE_CONTROL = "no-store, no-cache, must-revalidate, max-age=0"
|
||||
|
||||
|
||||
async def health_route(request: web.Request) -> web.Response:
|
||||
@@ -47,7 +50,7 @@ async def _css_asset_route(request: web.Request, *, base_name: str) -> web.Respo
|
||||
allow_precompressed=bool(asset_hash),
|
||||
)
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
|
||||
"public, max-age=31536000, immutable" if asset_hash else WEBAPP_LEGACY_ASSET_CACHE_CONTROL
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -206,9 +209,6 @@ async def theme_asset_route(request: web.Request) -> web.Response:
|
||||
|
||||
|
||||
def _resolve_webapp_logo_url(settings: Settings) -> str:
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return ""
|
||||
|
||||
raw_logo_url = (getattr(settings, "WEBAPP_LOGO_URL", None) or "").strip()
|
||||
if not raw_logo_url:
|
||||
return WEBAPP_DEFAULT_LOGO_PATH
|
||||
@@ -303,29 +303,8 @@ def _uploaded_webapp_logo_response(filename: str) -> web.Response:
|
||||
return response
|
||||
|
||||
|
||||
def _emoji_to_codepoints(value: str) -> str:
|
||||
return "_".join(f"{ord(char):x}" for char in str(value or "").strip())
|
||||
|
||||
|
||||
def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path:
|
||||
return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}"
|
||||
|
||||
|
||||
def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str:
|
||||
return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}"
|
||||
|
||||
|
||||
def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str:
|
||||
codepoints = _emoji_to_codepoints(emoji)
|
||||
if not codepoints or ext not in {"gif", "webp"}:
|
||||
return ""
|
||||
return f"/webapp-emoji/{codepoints}/512.{ext}"
|
||||
|
||||
|
||||
async def webapp_logo_route(request: web.Request) -> web.Response:
|
||||
settings: Settings = request.app["settings"]
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
raise web.HTTPNotFound(text="webapp_logo_disabled")
|
||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw_logo_url:
|
||||
raise web.HTTPNotFound(text="webapp_logo_not_configured")
|
||||
@@ -405,11 +384,15 @@ async def webapp_current_favicon_route(request: web.Request) -> web.Response:
|
||||
favicon_url = _resolve_webapp_favicon_url(settings, _resolve_webapp_logo_url(settings))
|
||||
digest = _webapp_generated_favicon_digest(favicon_url)
|
||||
if digest:
|
||||
return _webapp_favicon_file_response(digest, target_filename)
|
||||
response = _webapp_favicon_file_response(digest, target_filename)
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
return response
|
||||
|
||||
redirect_url = _webapp_redirectable_favicon_url(favicon_url, target_filename)
|
||||
if redirect_url:
|
||||
raise web.HTTPFound(location=redirect_url)
|
||||
redirect = web.HTTPFound(location=redirect_url)
|
||||
redirect.headers["Cache-Control"] = "no-cache"
|
||||
raise redirect
|
||||
|
||||
raise web.HTTPNotFound(text="webapp_favicon_not_found")
|
||||
|
||||
@@ -521,37 +504,8 @@ def _webapp_default_brand_file_response(path: Path, content_type: str) -> web.Re
|
||||
return web.Response(body=body, content_type=content_type)
|
||||
|
||||
|
||||
async def webapp_animated_emoji_route(request: web.Request) -> web.Response:
|
||||
codepoints = str(request.match_info.get("codepoints") or "").strip().lower()
|
||||
ext = str(request.match_info.get("ext") or "").strip().lower()
|
||||
if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}:
|
||||
raise web.HTTPNotFound(text="webapp_emoji_not_found")
|
||||
|
||||
emoji_cache_key = f"{codepoints}:{ext}"
|
||||
emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {})
|
||||
emoji_cache = emoji_caches.get(emoji_cache_key)
|
||||
if emoji_cache is None:
|
||||
cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
|
||||
async with cache_lock:
|
||||
emoji_cache = emoji_caches.get(emoji_cache_key)
|
||||
if emoji_cache is None:
|
||||
emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
|
||||
if emoji_cache:
|
||||
emoji_caches[emoji_cache_key] = emoji_cache
|
||||
|
||||
if not emoji_cache:
|
||||
raise web.HTTPNotFound(text="webapp_emoji_unavailable")
|
||||
|
||||
body, content_type = emoji_cache
|
||||
response = web.Response(body=body, content_type=content_type)
|
||||
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
|
||||
return response
|
||||
|
||||
|
||||
async def _warm_webapp_logo_cache(app: web.Application) -> None:
|
||||
settings: Settings = app["settings"]
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return
|
||||
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url):
|
||||
return
|
||||
@@ -573,111 +527,6 @@ async def _warm_webapp_logo_cache(app: web.Application) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None:
|
||||
settings: Settings = app["settings"]
|
||||
if not getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return
|
||||
if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated":
|
||||
return
|
||||
|
||||
codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI)
|
||||
if not codepoints:
|
||||
return
|
||||
|
||||
app.setdefault("webapp_emoji_cache", {})
|
||||
app.setdefault("webapp_emoji_cache_lock", asyncio.Lock())
|
||||
emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"]
|
||||
|
||||
for ext in ("gif", "webp"):
|
||||
emoji_cache_key = f"{codepoints}:{ext}"
|
||||
if emoji_cache_key in emoji_caches:
|
||||
continue
|
||||
loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext)
|
||||
if loaded_emoji:
|
||||
emoji_caches[emoji_cache_key] = loaded_emoji
|
||||
if ext == "gif":
|
||||
return
|
||||
|
||||
|
||||
async def _load_or_fetch_webapp_animated_emoji(
|
||||
codepoints: str, ext: str
|
||||
) -> Optional[Tuple[bytes, str]]:
|
||||
disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext)
|
||||
if disk_emoji:
|
||||
return disk_emoji
|
||||
|
||||
fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext)
|
||||
if fetched_emoji:
|
||||
await asyncio.to_thread(
|
||||
_write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji
|
||||
)
|
||||
return fetched_emoji
|
||||
|
||||
|
||||
def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
|
||||
path = _webapp_emoji_disk_path(codepoints, ext)
|
||||
try:
|
||||
body = path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
||||
return None
|
||||
return body, "image/gif" if ext == "gif" else "image/webp"
|
||||
|
||||
|
||||
def _write_webapp_animated_emoji_to_disk(
|
||||
codepoints: str, ext: str, emoji: Tuple[bytes, str]
|
||||
) -> None:
|
||||
body, _content_type = emoji
|
||||
if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
||||
return
|
||||
|
||||
path = _webapp_emoji_disk_path(codepoints, ext)
|
||||
try:
|
||||
WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(body)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc)
|
||||
|
||||
|
||||
async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]:
|
||||
try:
|
||||
session = await _get_shared_http_session()
|
||||
timeout = ClientTimeout(total=4)
|
||||
source_url = _webapp_animated_emoji_source_url(codepoints, ext)
|
||||
async with session.get(
|
||||
source_url,
|
||||
allow_redirects=False,
|
||||
headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"},
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
return None
|
||||
|
||||
content_type = (
|
||||
(response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower()
|
||||
)
|
||||
expected_content_type = "image/gif" if ext == "gif" else "image/webp"
|
||||
if content_type and content_type != expected_content_type:
|
||||
return None
|
||||
|
||||
body = bytearray()
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > WEBAPP_EMOJI_MAX_BYTES:
|
||||
logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.")
|
||||
return None
|
||||
|
||||
if not body:
|
||||
return None
|
||||
|
||||
return bytes(body), expected_content_type
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]:
|
||||
disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url)
|
||||
if disk_logo:
|
||||
@@ -923,7 +772,6 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
||||
"traffic_packages": settings.traffic_packages,
|
||||
"stars_traffic_packages": settings.stars_traffic_packages,
|
||||
"support_url": settings.SUPPORT_LINK or "",
|
||||
"terms_url": settings.TERMS_OF_SERVICE_URL or "",
|
||||
"privacy_policy_url": settings.PRIVACY_POLICY_URL or "",
|
||||
"user_agreement_url": settings.USER_AGREEMENT_URL or "",
|
||||
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
|
||||
@@ -937,9 +785,31 @@ def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]:
|
||||
def _resolve_app_version() -> str:
|
||||
# Single source of truth shared with the telemetry worker so the admin
|
||||
# sidebar and the install beacon always report the same version.
|
||||
from bot.utils.app_version import resolve_app_version
|
||||
from bot.utils import app_version as app_version_module
|
||||
|
||||
return resolve_app_version()
|
||||
global _APP_VERSION_CACHE
|
||||
|
||||
app_version_module.APP_ROOT = APP_ROOT
|
||||
app_version_module._run_git_command = _run_git_command
|
||||
app_version_module._APP_VERSION_CACHE = _APP_VERSION_CACHE
|
||||
version = app_version_module.resolve_app_version()
|
||||
_APP_VERSION_CACHE = app_version_module._APP_VERSION_CACHE
|
||||
return version
|
||||
|
||||
|
||||
def _run_git_command(*args: str) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=APP_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=1.5,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
async def _enforce_webapp_rate_limit(
|
||||
@@ -1033,7 +903,7 @@ async def _js_asset_route(request: web.Request, *, base_name: str) -> web.Respon
|
||||
strip_dev_mock=not asset_hash,
|
||||
)
|
||||
response.headers["Cache-Control"] = (
|
||||
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
|
||||
"public, max-age=31536000, immutable" if asset_hash else WEBAPP_LEGACY_ASSET_CACHE_CONTROL
|
||||
)
|
||||
return response
|
||||
|
||||
@@ -1144,9 +1014,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
"themesDir": settings.WEBAPP_THEMES_DIR,
|
||||
"themePreviewKey": preview_key,
|
||||
"logoUrl": cached["logo_url"],
|
||||
"logoUseEmoji": bool(settings.WEBAPP_LOGO_USE_EMOJI),
|
||||
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
|
||||
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
|
||||
"faviconUrl": cached["favicon_url"],
|
||||
"faviconUseCustom": bool(settings.WEBAPP_FAVICON_USE_CUSTOM),
|
||||
"apiBase": "/api",
|
||||
@@ -1157,7 +1024,6 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
|
||||
"telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0,
|
||||
"telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings),
|
||||
"supportUrl": cached["support_url"],
|
||||
"termsUrl": cached["terms_url"],
|
||||
"privacyPolicyUrl": cached["privacy_policy_url"],
|
||||
"userAgreementUrl": cached["user_agreement_url"],
|
||||
"currency": cached["currency"],
|
||||
@@ -1276,9 +1142,11 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
bootstrap = _build_webapp_bootstrap_payload(request)
|
||||
config = bootstrap["config"]
|
||||
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
|
||||
css_asset_name = _resolve_webapp_css_asset_name()
|
||||
js_asset_name = _resolve_webapp_js_asset_name()
|
||||
html = html.replace(
|
||||
'href="/subscription_webapp.css"',
|
||||
f'href="/{_resolve_webapp_css_asset_name()}"',
|
||||
f'href="/{css_asset_name}"',
|
||||
1,
|
||||
)
|
||||
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
|
||||
@@ -1305,15 +1173,9 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
html = html.replace(
|
||||
WEBAPP_JS_PLACEHOLDER,
|
||||
f'<script src="/{_resolve_webapp_js_asset_name()}" defer></script>',
|
||||
f'<script src="/{js_asset_name}" defer></script>',
|
||||
)
|
||||
brand_asset_url = cached["logo_url"]
|
||||
if (
|
||||
not brand_asset_url
|
||||
and settings.WEBAPP_LOGO_USE_EMOJI
|
||||
and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated"
|
||||
):
|
||||
brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI)
|
||||
if brand_asset_url:
|
||||
html = html.replace(
|
||||
"</head>",
|
||||
@@ -1324,7 +1186,9 @@ async def index_route(request: web.Request) -> web.Response:
|
||||
1,
|
||||
)
|
||||
response = web.Response(text=html, content_type="text/html", charset="utf-8")
|
||||
response.headers["Cache-Control"] = "no-cache"
|
||||
response.headers["Cache-Control"] = WEBAPP_HTML_CACHE_CONTROL
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
return response
|
||||
|
||||
|
||||
@@ -1335,6 +1199,11 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
|
||||
|
||||
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
|
||||
query = getattr(request, "query", {}) or {}
|
||||
themes_catalog = getattr(settings, "webapp_themes_catalog", None)
|
||||
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None) or "#00fe7a"
|
||||
initial_theme = (
|
||||
_initial_theme_for_request(request, themes_catalog) if themes_catalog is not None else None
|
||||
)
|
||||
lang = _normalize_language(query.get("lang") or getattr(settings, "DEFAULT_LANGUAGE", "ru"))
|
||||
messages = _app_deeplink_i18n_payload(request, lang)
|
||||
page_title = _webapp_page_title(settings, messages["title"])
|
||||
@@ -1351,6 +1220,14 @@ async def app_deeplink_route(request: web.Request) -> web.Response:
|
||||
.replace("__NONCE__", nonce)
|
||||
.replace("__MESSAGES_JSON__", messages_json)
|
||||
)
|
||||
initial_theme_markup = _app_deeplink_theme_head_markup(
|
||||
request,
|
||||
initial_theme,
|
||||
themes_catalog,
|
||||
primary_color,
|
||||
)
|
||||
if initial_theme_markup:
|
||||
html_text = html_text.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
|
||||
html_text = _apply_webapp_head_metadata(html_text, page_title, favicon_url)
|
||||
response = web.Response(text=html_text, content_type="text/html", charset="utf-8")
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
@@ -1577,10 +1454,15 @@ def _resolve_webapp_js_asset_name() -> str:
|
||||
|
||||
|
||||
def _resolve_webapp_admin_js_asset_name() -> str:
|
||||
# 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")
|
||||
# The admin bundle is lazy-loaded from the already running Mini App. It now
|
||||
# ships content-hashed alongside the main bundle (same build, deterministic
|
||||
# hashes, served immutable), so iOS WebViews fetch fresh admin assets on every
|
||||
# deploy. The App.svelte loader falls back to the bare runtime build name if a
|
||||
# hashed asset ever 404s.
|
||||
return _resolve_hashed_js_asset_name(
|
||||
kind="admin-js",
|
||||
base_name="subscription_webapp_admin",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
|
||||
@@ -1599,7 +1481,7 @@ def _resolve_hashed_js_asset_name(*, kind: str, base_name: str) -> str:
|
||||
if minified_assets:
|
||||
minified_assets.sort(reverse=True)
|
||||
return _set_cached_asset_name(kind, minified_assets[0][1])
|
||||
return _set_cached_asset_name(kind, f"{base_name}.js")
|
||||
return _set_cached_asset_name(kind, _stable_asset_name_with_version(f"{base_name}.js"))
|
||||
|
||||
|
||||
def _resolve_webapp_css_asset_name() -> str:
|
||||
@@ -1610,9 +1492,11 @@ def _resolve_webapp_css_asset_name() -> str:
|
||||
|
||||
|
||||
def _resolve_webapp_admin_css_asset_name() -> str:
|
||||
# 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")
|
||||
# Content-hashed and immutable, same rationale as the admin JS bundle above.
|
||||
return _resolve_hashed_css_asset_name(
|
||||
kind="admin-css",
|
||||
base_name="subscription_webapp_admin",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
|
||||
@@ -1631,7 +1515,19 @@ def _resolve_hashed_css_asset_name(*, kind: str, base_name: str) -> str:
|
||||
if hashed_assets:
|
||||
hashed_assets.sort(reverse=True)
|
||||
return _set_cached_asset_name(kind, hashed_assets[0][1])
|
||||
return _set_cached_asset_name(kind, f"{base_name}.css")
|
||||
return _set_cached_asset_name(kind, _stable_asset_name_with_version(f"{base_name}.css"))
|
||||
|
||||
|
||||
def _stable_asset_name_with_version(filename: str) -> str:
|
||||
path = ASSET_DIR / filename
|
||||
try:
|
||||
stat = path.stat()
|
||||
except OSError:
|
||||
return filename
|
||||
|
||||
raw_version = f"{filename}:{int(stat.st_mtime_ns)}:{int(stat.st_size)}"
|
||||
version = hashlib.sha256(raw_version.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{filename}?v={version}"
|
||||
|
||||
|
||||
def _get_cached_asset_name(kind: str) -> Optional[str]:
|
||||
@@ -1683,6 +1579,9 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
|
||||
"font_sans": "--font-sans",
|
||||
"font_logo": "--font-logo",
|
||||
"font_mono": "--font-mono",
|
||||
"home_logo_scale": "--home-logo-scale",
|
||||
"home_logo_scale_desktop": "--home-logo-scale-desktop",
|
||||
"home_logo_scale_mobile": "--home-logo-scale-mobile",
|
||||
"admin_bg": "--admin-bg",
|
||||
"admin_surface": "--admin-surface",
|
||||
"admin_surface_2": "--admin-surface-2",
|
||||
@@ -1694,6 +1593,12 @@ _INITIAL_THEME_TOKEN_CSS_MAP = {
|
||||
"admin_dim": "--admin-dim",
|
||||
}
|
||||
|
||||
_INITIAL_THEME_LOGO_SCALE_TOKENS = {
|
||||
"home_logo_scale",
|
||||
"home_logo_scale_desktop",
|
||||
"home_logo_scale_mobile",
|
||||
}
|
||||
|
||||
|
||||
def _theme_css_href_for_html(theme: Any) -> str:
|
||||
css_file = str(getattr(theme, "css_file", "") or "").strip()
|
||||
@@ -1716,7 +1621,8 @@ def _theme_css_href_for_html(theme: Any) -> str:
|
||||
|
||||
|
||||
def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
|
||||
preview_key = str(request.query.get("theme_preview") or "").strip()
|
||||
query = getattr(request, "query", {}) or {}
|
||||
preview_key = str(query.get("theme_preview") or "").strip()
|
||||
if preview_key:
|
||||
preview_theme = catalog.theme_by_key(preview_key)
|
||||
if preview_theme is not None and preview_theme.enabled:
|
||||
@@ -1728,18 +1634,38 @@ def _initial_theme_for_request(request: web.Request, catalog: Any) -> Any:
|
||||
return catalog.enabled_themes()[0] if catalog.enabled_themes() else None
|
||||
|
||||
|
||||
def _initial_theme_tokens(theme: Any, primary_color: str) -> Dict[str, Any]:
|
||||
if theme is None:
|
||||
return {}
|
||||
|
||||
payload = public_theme_payload(theme, primary_color)
|
||||
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
|
||||
return tokens if isinstance(tokens, dict) else {}
|
||||
|
||||
|
||||
def _initial_theme_declarations(tokens: Dict[str, Any]) -> List[str]:
|
||||
declarations = []
|
||||
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
|
||||
if token_key in _INITIAL_THEME_LOGO_SCALE_TOKENS:
|
||||
try:
|
||||
scale = float(tokens.get(token_key) or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if scale > 0:
|
||||
declarations.append(f"{css_name}:{scale / 100:g}")
|
||||
continue
|
||||
value = str(tokens.get(token_key) or "").strip()
|
||||
if value:
|
||||
declarations.append(f"{css_name}:{value}")
|
||||
return declarations
|
||||
|
||||
|
||||
def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color: str) -> str:
|
||||
if theme is None:
|
||||
return ""
|
||||
|
||||
payload = public_theme_payload(theme, primary_color)
|
||||
tokens = payload.get("tokens") if isinstance(payload, dict) else {}
|
||||
tokens = tokens if isinstance(tokens, dict) else {}
|
||||
declarations = []
|
||||
for token_key, css_name in _INITIAL_THEME_TOKEN_CSS_MAP.items():
|
||||
value = str(tokens.get(token_key) or "").strip()
|
||||
if value:
|
||||
declarations.append(f"{css_name}:{value}")
|
||||
tokens = _initial_theme_tokens(theme, primary_color)
|
||||
declarations = _initial_theme_declarations(tokens)
|
||||
|
||||
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
|
||||
bg = str(tokens.get("bg") or "").strip()
|
||||
@@ -1763,6 +1689,37 @@ def _initial_theme_head_markup(request: web.Request, theme: Any, primary_color:
|
||||
return stylesheet + "\n" + style_tag
|
||||
|
||||
|
||||
def _app_deeplink_theme_head_markup(
|
||||
request: web.Request,
|
||||
theme: Any,
|
||||
catalog: Any,
|
||||
primary_color: str,
|
||||
) -> str:
|
||||
tokens = _initial_theme_tokens(theme, primary_color)
|
||||
declarations = _initial_theme_declarations(tokens)
|
||||
try:
|
||||
accent = effective_webapp_theme_accent(
|
||||
catalog,
|
||||
primary_color,
|
||||
theme_key=str(getattr(theme, "key", "") or "") or None,
|
||||
)
|
||||
except Exception:
|
||||
accent = str(primary_color or "#00fe7a").strip() or "#00fe7a"
|
||||
if accent and not any(item.startswith("--accent:") for item in declarations):
|
||||
declarations.insert(0, f"--accent:{accent}")
|
||||
if not declarations:
|
||||
return ""
|
||||
|
||||
scheme = "light" if tokens.get("color_scheme") == "light" else "dark"
|
||||
nonce = html.escape(str(request.get("csp_nonce", "")), quote=True)
|
||||
return (
|
||||
f'<style id="webapp-initial-theme" nonce="{nonce}">'
|
||||
f"html{{color-scheme:{scheme};}}"
|
||||
f":root{{{';'.join(declarations)}}}"
|
||||
"</style>"
|
||||
)
|
||||
|
||||
|
||||
def _favicon_head_markup(favicon_url: str) -> str:
|
||||
href = str(favicon_url or "").strip()
|
||||
if not href:
|
||||
|
||||
@@ -713,6 +713,7 @@ async def email_auth_verify_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -821,6 +822,7 @@ async def email_auth_magic_route(request: web.Request) -> web.Response:
|
||||
session,
|
||||
referral_param,
|
||||
current_user_id=None,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, _ = await user_dal.create_email_user(
|
||||
session,
|
||||
@@ -1010,13 +1012,53 @@ async def _request_email_code(
|
||||
|
||||
|
||||
def _telegram_id_for_user(user: User) -> Optional[int]:
|
||||
if user.telegram_id:
|
||||
return int(user.telegram_id)
|
||||
if user.user_id and int(user.user_id) > 0:
|
||||
return int(user.user_id)
|
||||
telegram_id = getattr(user, "telegram_id", None)
|
||||
if telegram_id:
|
||||
return int(telegram_id)
|
||||
user_id = getattr(user, "user_id", None)
|
||||
if user_id and int(user_id) > 0:
|
||||
return int(user_id)
|
||||
return None
|
||||
|
||||
|
||||
def _user_has_linked_telegram(user: User) -> bool:
|
||||
return bool(getattr(user, "telegram_id", None))
|
||||
|
||||
|
||||
def _email_only_telegram_required_reason(
|
||||
settings: Settings,
|
||||
user: User,
|
||||
*,
|
||||
without_telegram_enabled_attr: str,
|
||||
) -> Optional[str]:
|
||||
if _user_has_linked_telegram(user):
|
||||
return None
|
||||
if is_disposable_email(getattr(user, "email", None), settings):
|
||||
return "disposable_email"
|
||||
if not bool(getattr(settings, without_telegram_enabled_attr, True)):
|
||||
return "telegram_required"
|
||||
return None
|
||||
|
||||
|
||||
def _trial_telegram_required_reason(settings: Settings, user: User) -> Optional[str]:
|
||||
return _email_only_telegram_required_reason(
|
||||
settings,
|
||||
user,
|
||||
without_telegram_enabled_attr="TRIAL_WITHOUT_TELEGRAM_ENABLED",
|
||||
)
|
||||
|
||||
|
||||
def _referral_welcome_telegram_required_reason(
|
||||
settings: Settings,
|
||||
user: User,
|
||||
) -> Optional[str]:
|
||||
return _email_only_telegram_required_reason(
|
||||
settings,
|
||||
user,
|
||||
without_telegram_enabled_attr="REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED",
|
||||
)
|
||||
|
||||
|
||||
def _panel_description_for_user(user: User) -> str:
|
||||
return panel_description_from_profile(
|
||||
user.username,
|
||||
@@ -1292,17 +1334,35 @@ async def _link_telegram_to_user(
|
||||
return current_user
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
def _remnashop_referral_compat_enabled(settings: Optional[Settings]) -> bool:
|
||||
if settings is None:
|
||||
return False
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _strip_referral_param_prefix(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
preserve_current_u_prefix: bool,
|
||||
) -> str:
|
||||
value = (raw or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
return ""
|
||||
|
||||
value_lower = value.lower()
|
||||
if value_lower.startswith("ref_u"):
|
||||
if value_lower.startswith("ref_u") and not preserve_current_u_prefix:
|
||||
value = value[5:]
|
||||
elif value_lower.startswith("ref_"):
|
||||
value = value[4:]
|
||||
elif value and value[0].lower() == "u" and len(value) == 10:
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=False)
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if value and value[0].lower() == "u" and len(value) == 10:
|
||||
value = value[1:]
|
||||
|
||||
if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value):
|
||||
@@ -1310,26 +1370,64 @@ def _normalize_referral_param(raw: Optional[str]) -> Optional[str]:
|
||||
return value.upper()
|
||||
|
||||
|
||||
def _referral_param_lookup_candidates(
|
||||
raw: Optional[str],
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> List[str]:
|
||||
if not remnashop_compat:
|
||||
normalized = _normalize_referral_param(raw)
|
||||
return [normalized] if normalized else []
|
||||
|
||||
value = _strip_referral_param_prefix(raw, preserve_current_u_prefix=True)
|
||||
if not value or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", value):
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
candidates.append(value[1:])
|
||||
|
||||
unique: List[str] = []
|
||||
for candidate in candidates:
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_id(
|
||||
session: AsyncSession,
|
||||
raw_referral_param: Optional[str],
|
||||
*,
|
||||
current_user_id: Optional[int],
|
||||
settings: Optional[Settings] = None,
|
||||
) -> Optional[int]:
|
||||
normalized = _normalize_referral_param(raw_referral_param)
|
||||
if not normalized:
|
||||
remnashop_compat = _remnashop_referral_compat_enabled(settings)
|
||||
candidates = _referral_param_lookup_candidates(
|
||||
raw_referral_param,
|
||||
remnashop_compat=remnashop_compat,
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
ref_user = None
|
||||
if normalized.isdigit():
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized)
|
||||
if not ref_user:
|
||||
return None
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
return None
|
||||
return int(ref_user.user_id)
|
||||
for normalized in candidates:
|
||||
ref_user = None
|
||||
if normalized.isdigit() and not remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
normalized,
|
||||
include_legacy=remnashop_compat,
|
||||
)
|
||||
if not ref_user and normalized.isdigit() and remnashop_compat:
|
||||
ref_user = await user_dal.get_user_by_id(session, int(normalized))
|
||||
if not ref_user:
|
||||
continue
|
||||
if current_user_id is not None and int(ref_user.user_id) == int(current_user_id):
|
||||
continue
|
||||
return int(ref_user.user_id)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _apply_referral_to_existing_user(
|
||||
@@ -1345,6 +1443,7 @@ async def _apply_referral_to_existing_user(
|
||||
session,
|
||||
raw_referral_param,
|
||||
current_user_id=int(user.user_id),
|
||||
settings=request.app["settings"],
|
||||
)
|
||||
if not referred_by_id:
|
||||
return False
|
||||
@@ -1374,6 +1473,21 @@ async def _apply_referral_welcome_bonus_if_needed(
|
||||
if not raw_referral_param or not user.referred_by_id:
|
||||
return None
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
if _referral_welcome_telegram_required_reason(settings, user):
|
||||
return None
|
||||
|
||||
return await _grant_referral_welcome_bonus_if_eligible(request, session, user)
|
||||
|
||||
|
||||
async def _grant_referral_welcome_bonus_if_eligible(
|
||||
request: web.Request,
|
||||
session: AsyncSession,
|
||||
user: User,
|
||||
) -> Optional[datetime]:
|
||||
if not user.referred_by_id:
|
||||
return None
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
referral_welcome_days = max(
|
||||
0,
|
||||
@@ -1383,6 +1497,10 @@ async def _apply_referral_welcome_bonus_if_needed(
|
||||
return None
|
||||
|
||||
subscription_service: SubscriptionService = request.app["subscription_service"]
|
||||
default_tariff_key = None
|
||||
tariffs_config = getattr(settings, "tariffs_config", None)
|
||||
if tariffs_config:
|
||||
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
|
||||
try:
|
||||
if await subscription_service.has_active_subscription(session, int(user.user_id)):
|
||||
return None
|
||||
@@ -1394,6 +1512,68 @@ async def _apply_referral_welcome_bonus_if_needed(
|
||||
int(user.user_id),
|
||||
referral_welcome_days,
|
||||
reason="referral_welcome_bonus",
|
||||
tariff_key=default_tariff_key,
|
||||
)
|
||||
|
||||
|
||||
def _webapp_datetime_text(value: Optional[datetime]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
async def referral_welcome_bonus_claim_route(request: web.Request) -> web.Response:
|
||||
user_id = _require_user_id(request)
|
||||
rate_limit_response = await _enforce_webapp_rate_limit(
|
||||
request,
|
||||
user_id=user_id,
|
||||
action="referral_welcome_claim",
|
||||
)
|
||||
if rate_limit_response:
|
||||
return rate_limit_response
|
||||
|
||||
settings: Settings = request.app["settings"]
|
||||
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
await session.rollback()
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
|
||||
reason = _referral_welcome_telegram_required_reason(settings, db_user)
|
||||
if reason:
|
||||
await session.rollback()
|
||||
return _json_error(400, "referral_welcome_telegram_required", reason)
|
||||
|
||||
end_date = await _grant_referral_welcome_bonus_if_eligible(
|
||||
request,
|
||||
session,
|
||||
db_user,
|
||||
)
|
||||
if not end_date:
|
||||
await session.rollback()
|
||||
return _json_error(
|
||||
400,
|
||||
"referral_welcome_unavailable",
|
||||
"Referral welcome bonus is not available",
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logger.exception("Referral welcome bonus claim failed")
|
||||
return _json_error(500, "referral_welcome_failed", "Referral welcome bonus failed")
|
||||
|
||||
await _invalidate_webapp_user_caches(settings, user_id, include_devices=True)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"claimed": True,
|
||||
"end_date": end_date.isoformat() if isinstance(end_date, datetime) else None,
|
||||
"end_date_text": _webapp_datetime_text(end_date),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1427,6 +1607,7 @@ async def _ensure_user_from_telegram(
|
||||
session,
|
||||
referral_param or telegram_user.get("start_param"),
|
||||
current_user_id=user_id,
|
||||
settings=settings,
|
||||
)
|
||||
db_user, created = await user_dal.create_user(
|
||||
session,
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.auth import _trial_telegram_required_reason
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
from db.dal import message_log_dal
|
||||
|
||||
|
||||
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||
|
||||
|
||||
def _plain_text_message(value: Any) -> str:
|
||||
"""Strip Telegram-style HTML markup from a localized message for the web app."""
|
||||
text = _HTML_TAG_RE.sub("", str(value))
|
||||
return html.unescape(text).strip()
|
||||
|
||||
|
||||
def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||
@@ -68,7 +79,7 @@ async def apply_promo_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
if not success:
|
||||
await session.commit()
|
||||
return _json_error(400, "promo_apply_failed", str(result))
|
||||
return _json_error(400, "promo_apply_failed", _plain_text_message(result))
|
||||
await session.commit()
|
||||
end_date = result if isinstance(result, datetime) else None
|
||||
return web.json_response(
|
||||
@@ -111,10 +122,11 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
hwid_quote: Optional[Dict[str, Any]] = None
|
||||
requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or ""))
|
||||
|
||||
if tariffs_config and requested_sale_mode == "hwid_devices_renewal":
|
||||
return _json_error(400, "invalid_plan", "Device renewal is part of subscription renewal")
|
||||
if tariffs_config and requested_sale_mode in {
|
||||
"hwid_device",
|
||||
"hwid_devices",
|
||||
"hwid_devices_renewal",
|
||||
}:
|
||||
tariff_key = str(payment_payload.tariff_key or "").strip()
|
||||
if not tariff_key:
|
||||
@@ -308,7 +320,7 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
user_id=user_id,
|
||||
device_count=int(payment_units),
|
||||
tariff_key=sale_tariff_key,
|
||||
renewal=_sale_mode_base(sale_mode) == "hwid_devices_renewal",
|
||||
renewal=False,
|
||||
currency=currency,
|
||||
)
|
||||
if not hwid_quote:
|
||||
@@ -321,6 +333,25 @@ async def create_payment_route(request: web.Request) -> web.Response:
|
||||
else:
|
||||
price = float(hwid_quote["price"])
|
||||
stars_price = None
|
||||
elif _sale_mode_base(sale_mode) == "subscription" and bool(
|
||||
payment_payload.renew_hwid_devices
|
||||
):
|
||||
currency = "stars" if method == "stars" else default_currency
|
||||
sale_tariff_key = _sale_mode_tariff_key(sale_mode)
|
||||
if sale_tariff_key:
|
||||
hwid_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=sale_tariff_key,
|
||||
months=int(payment_units),
|
||||
currency=currency,
|
||||
)
|
||||
if hwid_quote:
|
||||
if method == "stars":
|
||||
stars_price = int(stars_price or 0) + int(hwid_quote["price"])
|
||||
else:
|
||||
price = float(price or 0) + float(hwid_quote["price"])
|
||||
stars_price = None
|
||||
admin_ids = {int(item) for item in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
return await _create_subscription_payment(
|
||||
@@ -360,6 +391,13 @@ async def activate_trial_route(request: web.Request) -> web.Response:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or db_user.is_banned:
|
||||
return _json_error(403, "access_denied", "Access denied")
|
||||
telegram_required_reason = _trial_telegram_required_reason(settings, db_user)
|
||||
if telegram_required_reason:
|
||||
return _json_error(
|
||||
400,
|
||||
"trial_telegram_required",
|
||||
telegram_required_reason,
|
||||
)
|
||||
|
||||
activation_result = await subscription_service.activate_trial_subscription(session, user_id)
|
||||
if not activation_result or not activation_result.get("activated"):
|
||||
@@ -395,6 +433,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
|
||||
|
||||
@@ -659,7 +719,6 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
return _json_error(400, "device_topup_unavailable", "Device top-up is not available")
|
||||
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
renewal_available = bool(active and active.get("device_topup_renewal_available"))
|
||||
extra_hwid_valid_until = active.get("extra_hwid_devices_valid_until") if active else None
|
||||
extra_hwid_valid_until_text = (
|
||||
active.get("extra_hwid_devices_valid_until_text") if active else None
|
||||
@@ -681,7 +740,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
user_id=user_id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal_available,
|
||||
renewal=False,
|
||||
currency=default_currency,
|
||||
)
|
||||
if count in currency_counts
|
||||
@@ -693,7 +752,7 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
user_id=user_id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=renewal_available,
|
||||
renewal=False,
|
||||
currency="stars",
|
||||
)
|
||||
if count in stars_counts
|
||||
@@ -701,28 +760,27 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
)
|
||||
if not currency_quote and not stars_quote:
|
||||
continue
|
||||
sale_mode_for_plan = "hwid_devices_renewal" if renewal_available else "hwid_devices"
|
||||
quote = currency_quote or stars_quote
|
||||
valid_from = quote.get("valid_from")
|
||||
valid_until = quote.get("valid_until")
|
||||
plan = {
|
||||
"id": f"{tariff.key}:hwid:{count}{':renewal' if renewal_available else ''}",
|
||||
"id": f"{tariff.key}:hwid:{count}",
|
||||
"tariff_key": tariff.key,
|
||||
"tariff_name": tariff.name(lang),
|
||||
"billing_model": tariff.billing_model,
|
||||
"sale_mode": sale_mode_for_plan,
|
||||
"sale_mode": "hwid_devices",
|
||||
"renewal": False,
|
||||
"months": count,
|
||||
"device_count": count,
|
||||
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||
"currency": default_currency_code,
|
||||
"title": f"+{count}",
|
||||
"subtitle": tariff.name(lang),
|
||||
"valid_from": _billing_iso_datetime(
|
||||
(currency_quote or stars_quote).get("valid_from")
|
||||
),
|
||||
"valid_until": _billing_iso_datetime(
|
||||
(currency_quote or stars_quote).get("valid_until")
|
||||
),
|
||||
"proration_ratio": float(
|
||||
(currency_quote or stars_quote).get("proration_ratio") or 0
|
||||
),
|
||||
"valid_from": _billing_iso_datetime(valid_from),
|
||||
"valid_from_text": _billing_datetime_text(valid_from),
|
||||
"valid_until": _billing_iso_datetime(valid_until),
|
||||
"valid_until_text": _billing_datetime_text(valid_until),
|
||||
"proration_ratio": float(quote.get("proration_ratio") or 0),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
plan["stars_price"] = int(stars_quote["price"])
|
||||
@@ -738,10 +796,8 @@ async def device_topup_options_route(request: web.Request) -> web.Response:
|
||||
else int(sub.extra_hwid_devices or 0),
|
||||
"extra_hwid_devices_valid_until": _billing_iso_datetime(extra_hwid_valid_until),
|
||||
"extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text,
|
||||
"renewal_available": renewal_available,
|
||||
"renewal_recommended_count": int(active.get("extra_hwid_devices") or 0)
|
||||
if active and renewal_available
|
||||
else 0,
|
||||
"renewal_available": False,
|
||||
"renewal_recommended_count": 0,
|
||||
"plans": plans,
|
||||
}
|
||||
)
|
||||
@@ -907,7 +963,11 @@ async def payment_status_route(request: web.Request) -> web.Response:
|
||||
payment = await _refresh_yookassa_payment_status(request, session, payment)
|
||||
payment = await _refresh_wata_payment_status(request, session, payment)
|
||||
if payment.status == "succeeded":
|
||||
await invalidate_webapp_user_caches(request.app["settings"], user_id)
|
||||
await invalidate_webapp_user_caches(
|
||||
request.app["settings"],
|
||||
user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
@@ -992,27 +1052,60 @@ async def _create_subscription_payment(
|
||||
"unsupported_currency",
|
||||
"Payment method does not support this currency",
|
||||
)
|
||||
return await provider_spec.create_webapp_payment(
|
||||
WebAppPaymentContext(
|
||||
request=request,
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
method=method,
|
||||
months=months,
|
||||
price=price,
|
||||
stars_price=stars_price,
|
||||
currency=payment_currency,
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
|
||||
if not provider_spec.is_usable_for_payment_amount(
|
||||
settings,
|
||||
payment_currency,
|
||||
price,
|
||||
):
|
||||
logger.warning(
|
||||
"WebApp payment method does not support amount: method=%s amount=%s currency=%s",
|
||||
method,
|
||||
price,
|
||||
payment_currency,
|
||||
)
|
||||
return _json_error(
|
||||
400,
|
||||
"payment_amount_below_minimum",
|
||||
"Payment amount is below the provider minimum",
|
||||
)
|
||||
payment_context = WebAppPaymentContext(
|
||||
request=request,
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
method=method,
|
||||
months=months,
|
||||
price=price,
|
||||
stars_price=stars_price,
|
||||
currency=payment_currency,
|
||||
description=description,
|
||||
sale_mode=sale_mode,
|
||||
traffic_gb=traffic_gb,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
hwid_valid_from=hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
hwid_valid_until=hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
hwid_pricing_period_months=hwid_quote.get("pricing_period_months")
|
||||
if hwid_quote
|
||||
else None,
|
||||
hwid_proration_ratio=hwid_quote.get("proration_ratio") if hwid_quote else None,
|
||||
hwid_full_price=hwid_quote.get("full_price") if hwid_quote else None,
|
||||
)
|
||||
if provider_spec.reuse_webapp_payment:
|
||||
from bot.payment_providers.shared import reusable_webapp_payment_response
|
||||
|
||||
try:
|
||||
reusable_response = await reusable_webapp_payment_response(
|
||||
payment_context,
|
||||
provider_spec,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to verify reusable payment: user_id=%s provider=%s",
|
||||
user_id,
|
||||
provider_spec.provider_key,
|
||||
)
|
||||
reusable_response = None
|
||||
if reusable_response is not None:
|
||||
return reusable_response
|
||||
return await provider_spec.create_webapp_payment(payment_context)
|
||||
|
||||
return _json_error(400, "payment_unavailable", "Payment method unavailable")
|
||||
|
||||
@@ -49,6 +49,7 @@ class WebAppPaymentCreatePayload(BaseModel):
|
||||
device_count: Any = None
|
||||
tariff_key: Optional[constr(max_length=128)] = None
|
||||
sale_mode: Optional[constr(max_length=64)] = None
|
||||
renew_hwid_devices: Optional[bool] = None
|
||||
description: Optional[constr(max_length=4096)] = None
|
||||
comment: Optional[constr(max_length=4096)] = None
|
||||
note: Optional[constr(max_length=4096)] = None
|
||||
|
||||
@@ -46,10 +46,6 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
rf"{WEBAPP_FAVICON_PATH}/{{digest:[0-9a-f]{{16}}}}/{{filename:[A-Za-z0-9_.-]+}}",
|
||||
webapp_favicon_route,
|
||||
)
|
||||
app.router.add_get(
|
||||
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
|
||||
webapp_animated_emoji_route,
|
||||
)
|
||||
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
|
||||
app.router.add_get("/subscription_webapp.css", css_asset_route)
|
||||
app.router.add_get(
|
||||
@@ -89,6 +85,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
|
||||
"/api/account/telegram/notifications/probe",
|
||||
account_telegram_notifications_probe_route,
|
||||
)
|
||||
app.router.add_post("/api/referral/welcome-bonus/claim", referral_welcome_bonus_claim_route)
|
||||
app.router.add_post("/api/promo/apply", apply_promo_route)
|
||||
app.router.add_post("/api/trial/activate", activate_trial_route)
|
||||
app.router.add_get("/api/devices", devices_route)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
# ruff: noqa: F401,F403,F405,I001
|
||||
from ._runtime import * # noqa: F403,F405
|
||||
|
||||
from bot.app.web.webapp.auth import (
|
||||
_referral_welcome_telegram_required_reason,
|
||||
_trial_telegram_required_reason,
|
||||
_user_has_linked_telegram,
|
||||
)
|
||||
from config.subscription_guides_config import subscription_guides_available
|
||||
from config.webapp_themes_config import public_themes_catalog_payload
|
||||
from bot.services.telegram_notifications import (
|
||||
@@ -64,20 +69,48 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
if active and local_sub
|
||||
else None
|
||||
)
|
||||
trial_available = bool(
|
||||
trial_base_available = bool(
|
||||
settings.TRIAL_ENABLED
|
||||
and settings.TRIAL_DURATION_DAYS > 0
|
||||
and not await subscription_service.has_trial_blocking_subscription(session, user_id)
|
||||
)
|
||||
trial_telegram_required_reason = (
|
||||
_trial_telegram_required_reason(settings, db_user) if trial_base_available else None
|
||||
)
|
||||
trial_available = bool(trial_base_available and not trial_telegram_required_reason)
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
plans_payload = _serialize_plans(
|
||||
settings,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
)
|
||||
await _attach_hwid_renewal_quotes_to_plans(
|
||||
session,
|
||||
subscription_service,
|
||||
user_id=user_id,
|
||||
settings=settings,
|
||||
active=active,
|
||||
local_sub=local_sub,
|
||||
plans=plans_payload,
|
||||
)
|
||||
avatar = await _ensure_cached_telegram_avatar(request, session, db_user)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
|
||||
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
|
||||
admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])}
|
||||
is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids)
|
||||
telegram_linked = _user_has_linked_telegram(db_user)
|
||||
referral_welcome_days = max(0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0))
|
||||
referral_welcome_telegram_required_reason = (
|
||||
_referral_welcome_telegram_required_reason(settings, db_user)
|
||||
if db_user.referred_by_id and not active and referral_welcome_days > 0
|
||||
else None
|
||||
)
|
||||
telegram_notifications_status = normalize_telegram_notification_status(
|
||||
getattr(db_user, "telegram_notifications_status", None)
|
||||
)
|
||||
@@ -94,7 +127,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
db_user.email and db_user.email_verified_at and db_user.password_hash
|
||||
),
|
||||
"telegram_id": db_user.telegram_id,
|
||||
"telegram_linked": bool(_telegram_id_for_user(db_user)),
|
||||
"telegram_linked": telegram_linked,
|
||||
"telegram_notifications_status": telegram_notifications_status,
|
||||
"telegram_notifications_enabled": (
|
||||
telegram_notifications_status == TELEGRAM_NOTIFICATIONS_ENABLED
|
||||
@@ -120,22 +153,20 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
"webapp_link": webapp_referral_link,
|
||||
"invited_count": referral_stats.get("invited_count", 0),
|
||||
"purchased_count": referral_stats.get("purchased_count", 0),
|
||||
"welcome_bonus_days": max(
|
||||
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
|
||||
"welcome_bonus_days": referral_welcome_days,
|
||||
"welcome_bonus_without_telegram_enabled": bool(
|
||||
getattr(settings, "REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED", True)
|
||||
),
|
||||
"welcome_bonus_requires_telegram": bool(
|
||||
referral_welcome_telegram_required_reason and not telegram_linked
|
||||
),
|
||||
"welcome_bonus_block_reason": referral_welcome_telegram_required_reason,
|
||||
"one_bonus_per_referee": bool(
|
||||
getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False)
|
||||
),
|
||||
"bonus_details": _serialize_referral_bonus_details(settings, lang),
|
||||
},
|
||||
"plans": _serialize_plans(
|
||||
settings,
|
||||
lang,
|
||||
subscription_options=cached["subscription_options"],
|
||||
stars_subscription_options=cached["stars_subscription_options"],
|
||||
traffic_packages=cached["traffic_packages"],
|
||||
stars_traffic_packages=cached["stars_traffic_packages"],
|
||||
),
|
||||
"plans": plans_payload,
|
||||
"payment_methods": _serialize_payment_methods(
|
||||
settings,
|
||||
request.app,
|
||||
@@ -164,6 +195,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
|
||||
),
|
||||
"trial_enabled": bool(settings.TRIAL_ENABLED),
|
||||
"trial_available": trial_available,
|
||||
"trial_without_telegram_enabled": bool(
|
||||
getattr(settings, "TRIAL_WITHOUT_TELEGRAM_ENABLED", True)
|
||||
),
|
||||
"trial_requires_telegram": bool(trial_telegram_required_reason and not telegram_linked),
|
||||
"trial_block_reason": trial_telegram_required_reason,
|
||||
"trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0),
|
||||
"trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0),
|
||||
"trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"),
|
||||
@@ -342,11 +378,12 @@ def _serialize_subscription(
|
||||
and tariff.premium_topup_packages.has_any()
|
||||
)
|
||||
can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic)
|
||||
# max_devices == 0 means unlimited — top-up is pointless in that case.
|
||||
max_devices = _coerce_int_or_none(active.get("max_devices"))
|
||||
# max_devices == 0 or None means unlimited — top-up is pointless in that case.
|
||||
can_topup_devices = bool(
|
||||
tariff.billing_model == "period"
|
||||
and tariff.has_hwid_device_packages()
|
||||
and _coerce_int_or_none(active.get("max_devices")) != 0
|
||||
and max_devices not in (None, 0)
|
||||
)
|
||||
except Exception:
|
||||
can_topup_regular_traffic = False
|
||||
@@ -437,6 +474,102 @@ def _serialize_subscription(
|
||||
}
|
||||
|
||||
|
||||
def _webapp_iso_datetime(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _webapp_datetime_text(value: Optional[Any]) -> Optional[str]:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
return normalized.strftime("%d.%m.%Y %H:%M")
|
||||
return str(value)
|
||||
|
||||
|
||||
async def _attach_hwid_renewal_quotes_to_plans(
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
*,
|
||||
user_id: int,
|
||||
settings: Settings,
|
||||
active: Optional[Dict[str, Any]],
|
||||
local_sub: Optional[Any],
|
||||
plans: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
quote_method = getattr(subscription_service, "quote_hwid_device_renewal_for_subscription", None)
|
||||
if not callable(quote_method):
|
||||
return
|
||||
if not active or not local_sub or not settings.tariffs_config:
|
||||
return
|
||||
if not active.get("end_date") or int(active.get("extra_hwid_devices") or 0) <= 0:
|
||||
return
|
||||
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
default_currency_code = payment_currency_code(default_currency)
|
||||
for plan in plans:
|
||||
if str(plan.get("sale_mode") or "subscription") != "subscription":
|
||||
continue
|
||||
target_tariff_key = str(plan.get("tariff_key") or "").strip()
|
||||
if not target_tariff_key:
|
||||
continue
|
||||
try:
|
||||
months = int(plan.get("months") or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if months <= 0:
|
||||
continue
|
||||
try:
|
||||
currency_quote = await quote_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=target_tariff_key,
|
||||
months=months,
|
||||
currency=default_currency,
|
||||
)
|
||||
stars_quote = await quote_method(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=target_tariff_key,
|
||||
months=months,
|
||||
currency="stars",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to quote HWID renewal for plan %s/%s",
|
||||
target_tariff_key,
|
||||
months,
|
||||
)
|
||||
continue
|
||||
quote = currency_quote or stars_quote
|
||||
if not quote:
|
||||
continue
|
||||
valid_from = quote.get("valid_from")
|
||||
valid_until = quote.get("valid_until")
|
||||
active_until = quote.get("active_until")
|
||||
renewal = {
|
||||
"available": True,
|
||||
"device_count": int(quote.get("device_count") or 0),
|
||||
"price": float(currency_quote.get("price") if currency_quote else 0),
|
||||
"currency": default_currency_code,
|
||||
"valid_from": _webapp_iso_datetime(valid_from),
|
||||
"valid_from_text": _webapp_datetime_text(valid_from),
|
||||
"valid_until": _webapp_iso_datetime(valid_until),
|
||||
"valid_until_text": _webapp_datetime_text(valid_until),
|
||||
"active_until": _webapp_iso_datetime(active_until),
|
||||
"active_until_text": _webapp_datetime_text(active_until),
|
||||
"pricing_period_months": int(quote.get("pricing_period_months") or months),
|
||||
}
|
||||
if stars_quote and int(stars_quote.get("price") or 0) > 0:
|
||||
renewal["stars_price"] = int(stars_quote["price"])
|
||||
plan["hwid_renewal"] = renewal
|
||||
|
||||
|
||||
def _build_install_share_link(
|
||||
request: Optional[web.Request],
|
||||
settings: Settings,
|
||||
@@ -495,7 +628,10 @@ def _serialize_plans(
|
||||
else [],
|
||||
}
|
||||
if tariff.billing_model == "period":
|
||||
for months in sorted(tariff.enabled_periods):
|
||||
# Render periods in the configured order (enabled_periods is the
|
||||
# source of truth for purchase-period ordering, matching the bot
|
||||
# keyboards). Do not sort so admins can reorder via drag & drop.
|
||||
for months in tariff.enabled_periods:
|
||||
price = tariff.period_price(int(months), default_currency)
|
||||
stars_price = tariff.period_price(int(months), "stars")
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
@@ -528,7 +664,14 @@ def _serialize_plans(
|
||||
tariff.traffic_packages.stars if tariff.traffic_packages else []
|
||||
)
|
||||
}
|
||||
for traffic_gb in sorted(set(currency_packages) | set(stars_packages)):
|
||||
# Preserve the configured package order (default-currency list first,
|
||||
# then any Stars-only volumes) so admins can reorder via drag & drop.
|
||||
# Matches the bot keyboard, which iterates the package list as-is.
|
||||
ordered_gb: List[float] = []
|
||||
for traffic_gb in list(currency_packages) + list(stars_packages):
|
||||
if traffic_gb not in ordered_gb:
|
||||
ordered_gb.append(traffic_gb)
|
||||
for traffic_gb in ordered_gb:
|
||||
price = currency_packages.get(traffic_gb)
|
||||
stars_price = stars_packages.get(traffic_gb)
|
||||
if price is None and (stars_price is None or int(stars_price) <= 0):
|
||||
@@ -800,13 +943,15 @@ def _serialize_payment_methods(
|
||||
and spec.is_usable_for_payment_currency(settings, payment_currency)
|
||||
):
|
||||
presentation = resolve_provider_presentation(spec, settings, language=lang)
|
||||
methods.append(
|
||||
{
|
||||
"id": method,
|
||||
"name": presentation.webapp_label,
|
||||
"icon": presentation.webapp_icon,
|
||||
}
|
||||
)
|
||||
payload = {
|
||||
"id": method,
|
||||
"name": presentation.webapp_label,
|
||||
"icon": presentation.webapp_icon,
|
||||
}
|
||||
minimum = spec.payment_minimum(settings, payment_currency)
|
||||
if minimum:
|
||||
payload.update(minimum)
|
||||
methods.append(payload)
|
||||
return methods
|
||||
|
||||
|
||||
|
||||
@@ -236,6 +236,10 @@ def get_user_card_keyboard(
|
||||
text=_(key="admin_user_traffic_grant_button"),
|
||||
callback_data=f"user_action:traffic_grant:{user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_(key="admin_user_hwid_limit_button"),
|
||||
callback_data=f"user_action:hwid_limit:{user_id}",
|
||||
)
|
||||
|
||||
# Row 4: Quick links — only for users with a real Telegram profile
|
||||
# (synthetic email-only users have a negative user_id with no tg profile).
|
||||
@@ -261,9 +265,9 @@ def get_user_card_keyboard(
|
||||
|
||||
quick_links_count = (1 if has_self_link else 0) + (1 if has_referrer_link else 0)
|
||||
if quick_links_count == 0:
|
||||
builder.adjust(2, 2, 2, 1, 2, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 3, 1, 2)
|
||||
else:
|
||||
builder.adjust(2, 2, 2, 1, 2, quick_links_count, 1, 2)
|
||||
builder.adjust(2, 2, 2, 1, 3, quick_links_count, 1, 2)
|
||||
return builder
|
||||
|
||||
|
||||
@@ -403,6 +407,26 @@ async def format_user_card(
|
||||
f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}"
|
||||
)
|
||||
|
||||
max_devices = subscription_details.get("max_devices")
|
||||
extra_hwid_devices = int(subscription_details.get("extra_hwid_devices") or 0)
|
||||
if max_devices is not None:
|
||||
if int(max_devices) == 0:
|
||||
devices_display = _("admin_hwid_limit_state_unlimited")
|
||||
elif extra_hwid_devices > 0:
|
||||
base_hwid_limit = subscription_details.get("base_hwid_device_limit")
|
||||
if base_hwid_limit is None:
|
||||
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||
else:
|
||||
devices_display = _(
|
||||
"admin_hwid_limit_state_with_extra",
|
||||
total=int(max_devices),
|
||||
base=int(base_hwid_limit),
|
||||
extra=extra_hwid_devices,
|
||||
)
|
||||
else:
|
||||
devices_display = _("admin_hwid_limit_state_count", count=int(max_devices))
|
||||
card_parts.append(f"{_('admin_user_hwid_limit_label')} {hcode(devices_display)}")
|
||||
|
||||
premium_unlimited = bool(subscription_details.get("premium_unlimited_override"))
|
||||
premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0)
|
||||
if premium_unlimited:
|
||||
@@ -706,6 +730,32 @@ async def user_action_handler(
|
||||
await handle_traffic_grant_prompt(callback, state, user, "regular", i18n, current_lang)
|
||||
elif action == "traffic_grant_premium":
|
||||
await handle_traffic_grant_prompt(callback, state, user, "premium", i18n, current_lang)
|
||||
elif action == "hwid_limit":
|
||||
await handle_hwid_limit_menu(callback, state, user, session, i18n, current_lang)
|
||||
elif action == "hwid_limit_set_unlimited":
|
||||
await handle_hwid_limit_apply(
|
||||
callback,
|
||||
user,
|
||||
subscription_service,
|
||||
session,
|
||||
settings,
|
||||
i18n,
|
||||
current_lang,
|
||||
hwid_device_limit=0,
|
||||
)
|
||||
elif action == "hwid_limit_reset":
|
||||
await handle_hwid_limit_apply(
|
||||
callback,
|
||||
user,
|
||||
subscription_service,
|
||||
session,
|
||||
settings,
|
||||
i18n,
|
||||
current_lang,
|
||||
hwid_device_limit=None,
|
||||
)
|
||||
elif action == "hwid_limit_set_number":
|
||||
await handle_hwid_limit_prompt(callback, state, user, i18n, current_lang)
|
||||
else:
|
||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||
|
||||
@@ -850,6 +900,162 @@ async def handle_premium_override_bonus_prompt(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
def _admin_hwid_limit_state_text(
|
||||
get_text: Callable[..., str],
|
||||
hwid_device_limit: Optional[int],
|
||||
extra_hwid_devices: int = 0,
|
||||
) -> str:
|
||||
if hwid_device_limit is None:
|
||||
return get_text("admin_hwid_limit_state_default")
|
||||
base_limit = int(hwid_device_limit)
|
||||
if base_limit == 0:
|
||||
return get_text("admin_hwid_limit_state_unlimited")
|
||||
extra = max(0, int(extra_hwid_devices or 0))
|
||||
if extra > 0:
|
||||
return get_text(
|
||||
"admin_hwid_limit_state_with_extra",
|
||||
total=base_limit + extra,
|
||||
base=base_limit,
|
||||
extra=extra,
|
||||
)
|
||||
return get_text("admin_hwid_limit_state_count", count=base_limit)
|
||||
|
||||
|
||||
async def handle_hwid_limit_menu(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
user: User,
|
||||
session: AsyncSession,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
) -> None:
|
||||
"""Show HWID device limit override controls."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(session, user.user_id)
|
||||
if not active_sub:
|
||||
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||
return
|
||||
|
||||
current_text = _admin_hwid_limit_state_text(
|
||||
_,
|
||||
getattr(active_sub, "hwid_device_limit", None),
|
||||
int(getattr(active_sub, "extra_hwid_devices", 0) or 0),
|
||||
)
|
||||
text = "\n".join(
|
||||
[
|
||||
f"<b>{_('admin_hwid_limit_title')}</b>",
|
||||
"",
|
||||
_("admin_hwid_limit_hint"),
|
||||
"",
|
||||
_("admin_hwid_limit_current", current=current_text),
|
||||
]
|
||||
)
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(
|
||||
text=_("admin_hwid_limit_btn_set_number"),
|
||||
callback_data=f"user_action:hwid_limit_set_number:{user.user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_("admin_hwid_limit_btn_unlimited"),
|
||||
callback_data=f"user_action:hwid_limit_set_unlimited:{user.user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_("admin_hwid_limit_btn_reset"),
|
||||
callback_data=f"user_action:hwid_limit_reset:{user.user_id}",
|
||||
)
|
||||
builder.button(
|
||||
text=_("admin_user_back_to_card_button"),
|
||||
callback_data=f"user_action:refresh:{user.user_id}",
|
||||
)
|
||||
builder.adjust(1, 1, 1, 1)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
except Exception:
|
||||
await callback.message.answer(text, reply_markup=builder.as_markup(), parse_mode="HTML")
|
||||
await state.update_data(target_user_id=user.user_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_hwid_limit_apply(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
settings: Settings,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
*,
|
||||
hwid_device_limit: Optional[int],
|
||||
) -> None:
|
||||
"""Persist a HWID device base limit override and push it to the panel."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user.user_id
|
||||
)
|
||||
if not active_sub:
|
||||
await callback.answer(_("admin_hwid_limit_no_subscription"), show_alert=True)
|
||||
return
|
||||
|
||||
active_sub.hwid_device_limit = hwid_device_limit
|
||||
|
||||
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||
session, user.user_id
|
||||
)
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": callback.from_user.id if callback.from_user else user.user_id,
|
||||
"event_type": "admin:hwid_device_limit",
|
||||
"content": (
|
||||
f"hwid_device_limit={hwid_device_limit!r} "
|
||||
f"effective_hwid_device_limit={effective_limit!r}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user.user_id,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
await callback.answer(_("admin_hwid_limit_saved"), show_alert=False)
|
||||
await handle_refresh_user_card(
|
||||
callback, user, subscription_service, session, settings, i18n_instance, lang
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Failed to apply HWID device limit for user %s: %s",
|
||||
user.user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
await callback.answer(_("admin_hwid_limit_save_error"), show_alert=True)
|
||||
|
||||
|
||||
async def handle_hwid_limit_prompt(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
user: User,
|
||||
i18n_instance,
|
||||
lang: str,
|
||||
) -> None:
|
||||
"""Ask admin for an explicit HWID device limit."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
await state.update_data(target_user_id=user.user_id)
|
||||
await state.set_state(AdminStates.waiting_for_hwid_device_limit)
|
||||
prompt = _("admin_hwid_limit_prompt", user_id=user.user_id)
|
||||
try:
|
||||
await callback.message.edit_text(prompt)
|
||||
except Exception:
|
||||
await callback.message.answer(prompt)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_traffic_grant_menu(
|
||||
callback: types.CallbackQuery,
|
||||
user: User,
|
||||
@@ -1971,6 +2177,114 @@ async def process_premium_override_bonus_handler(
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_hwid_device_limit, F.text)
|
||||
async def process_hwid_device_limit_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
"""Read explicit HWID device limit and apply it."""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await message.reply("Language service error.")
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
data = await state.get_data()
|
||||
target_user_id = data.get("target_user_id")
|
||||
if not target_user_id:
|
||||
await message.answer(_("admin_hwid_limit_state_missing"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
raw = (message.text or "").strip()
|
||||
try:
|
||||
hwid_device_limit = int(raw)
|
||||
if hwid_device_limit < 0 or hwid_device_limit > 1_000_000:
|
||||
raise ValueError("out_of_range")
|
||||
except (TypeError, ValueError):
|
||||
await message.answer(_("admin_hwid_limit_invalid"))
|
||||
return
|
||||
|
||||
target_user = await user_dal.get_user_by_id(session, target_user_id)
|
||||
if not target_user:
|
||||
await message.answer(_("admin_user_not_found_action"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, target_user_id
|
||||
)
|
||||
if not active_sub:
|
||||
await message.answer(_("admin_hwid_limit_no_subscription"))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
active_sub.hwid_device_limit = hwid_device_limit
|
||||
effective_limit = await subscription_service.sync_hwid_device_limit_to_panel(
|
||||
session, target_user_id
|
||||
)
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": message.from_user.id if message.from_user else target_user_id,
|
||||
"event_type": "admin:hwid_device_limit",
|
||||
"content": (
|
||||
f"hwid_device_limit={hwid_device_limit!r} "
|
||||
f"effective_hwid_device_limit={effective_limit!r}"
|
||||
),
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_user_id,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
current_text = _admin_hwid_limit_state_text(_, hwid_device_limit)
|
||||
await message.answer(
|
||||
_("admin_hwid_limit_set", current=current_text, user_id=target_user_id)
|
||||
)
|
||||
|
||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||
bot_username = await _resolve_bot_username(message.bot)
|
||||
user_card_text = await format_user_card(
|
||||
target_user,
|
||||
session,
|
||||
subscription_service,
|
||||
i18n,
|
||||
current_lang,
|
||||
referral_service,
|
||||
settings=settings,
|
||||
bot_username=bot_username,
|
||||
)
|
||||
keyboard = get_user_card_keyboard(
|
||||
target_user.user_id, i18n, current_lang, target_user.referred_by_id
|
||||
)
|
||||
await _send_with_profile_link_fallback(
|
||||
message.answer,
|
||||
text=user_card_text,
|
||||
markup=keyboard.as_markup(),
|
||||
user_id=target_user.user_id,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Error setting HWID device limit for user %s: %s",
|
||||
target_user_id,
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
await message.answer(_("admin_hwid_limit_save_error"))
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_traffic_grant_gb, F.text)
|
||||
async def process_traffic_grant_gb_handler(
|
||||
message: types.Message,
|
||||
|
||||
@@ -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,
|
||||
@@ -40,6 +41,67 @@ from db.models import User
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
|
||||
def _remnashop_referral_compat_enabled(settings: Settings) -> bool:
|
||||
return bool(getattr(settings, "MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED", False))
|
||||
|
||||
|
||||
def _referral_code_lookup_candidates(
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
remnashop_compat: bool,
|
||||
) -> list[str]:
|
||||
value = str(raw_ref_value or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
|
||||
candidates = [value]
|
||||
if value and value[0].lower() == "u":
|
||||
stripped_current_prefix = value[1:]
|
||||
if remnashop_compat:
|
||||
candidates.append(stripped_current_prefix)
|
||||
else:
|
||||
candidates = [stripped_current_prefix]
|
||||
|
||||
unique: list[str] = []
|
||||
for candidate in candidates:
|
||||
candidate = candidate.strip()
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
async def _resolve_referrer_from_start_ref(
|
||||
session: AsyncSession,
|
||||
raw_ref_value: str,
|
||||
*,
|
||||
settings: Settings,
|
||||
current_user_id: int,
|
||||
) -> Optional[int]:
|
||||
ref_user: Optional[User] = None
|
||||
if raw_ref_value.isdigit() and settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != current_user_id:
|
||||
ref_user = await user_dal.get_user_by_id(session, potential_referrer_id)
|
||||
|
||||
include_legacy = _remnashop_referral_compat_enabled(settings)
|
||||
if not ref_user:
|
||||
for code in _referral_code_lookup_candidates(
|
||||
raw_ref_value,
|
||||
remnashop_compat=include_legacy,
|
||||
):
|
||||
ref_user = await user_dal.get_user_by_referral_code(
|
||||
session,
|
||||
code,
|
||||
include_legacy=include_legacy,
|
||||
)
|
||||
if ref_user:
|
||||
break
|
||||
|
||||
if ref_user and ref_user.user_id != current_user_id:
|
||||
return int(ref_user.user_id)
|
||||
return None
|
||||
|
||||
|
||||
async def should_show_trial_button(
|
||||
settings: Settings,
|
||||
subscription_service: SubscriptionService,
|
||||
@@ -376,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")
|
||||
|
||||
@@ -410,14 +473,10 @@ async def ensure_required_channel_subscription(
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_([A-Za-z0-9_-]{1,64})$").as_("ref_match")))
|
||||
@router.message(
|
||||
CommandStart(
|
||||
magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_(
|
||||
"ref_match"
|
||||
)
|
||||
)
|
||||
CommandStart(magic=F.args.regexp(r"^promo_([A-Za-z0-9_-]{1,100})$").as_("promo_match"))
|
||||
)
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^notifications$").as_("notifications_match")))
|
||||
@@ -534,22 +593,12 @@ async def start_command_handler(
|
||||
|
||||
if ref_match:
|
||||
raw_ref_value = ref_match.group(1)
|
||||
if raw_ref_value.isdigit():
|
||||
if settings.LEGACY_REFS:
|
||||
potential_referrer_id = int(raw_ref_value)
|
||||
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||
session, potential_referrer_id
|
||||
):
|
||||
referred_by_user_id = potential_referrer_id
|
||||
else:
|
||||
normalized_code = raw_ref_value.strip()
|
||||
if normalized_code and normalized_code[0].lower() == "u":
|
||||
normalized_code = normalized_code[1:]
|
||||
ref_user = None
|
||||
if normalized_code:
|
||||
ref_user = await user_dal.get_user_by_referral_code(session, normalized_code)
|
||||
if ref_user and ref_user.user_id != user_id:
|
||||
referred_by_user_id = ref_user.user_id
|
||||
referred_by_user_id = await _resolve_referrer_from_start_ref(
|
||||
session,
|
||||
raw_ref_value,
|
||||
settings=settings,
|
||||
current_user_id=user_id,
|
||||
)
|
||||
elif promo_match:
|
||||
promo_code_to_apply = promo_match.group(1)
|
||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||
@@ -609,12 +658,17 @@ async def start_command_handler(
|
||||
)
|
||||
if referred_by_user_id and referral_welcome_days > 0:
|
||||
try:
|
||||
default_tariff_key = None
|
||||
tariffs_config = getattr(settings, "tariffs_config", None)
|
||||
if tariffs_config:
|
||||
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
|
||||
referral_bonus_end_date = (
|
||||
await subscription_service.extend_active_subscription_days(
|
||||
session,
|
||||
user_id,
|
||||
referral_welcome_days,
|
||||
reason="referral_welcome_bonus",
|
||||
tariff_key=default_tariff_key,
|
||||
)
|
||||
)
|
||||
if referral_bonus_end_date:
|
||||
@@ -1135,7 +1189,7 @@ async def main_action_callback_handler(
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
privacy_url = settings.PRIVACY_POLICY_URL
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL
|
||||
|
||||
if not privacy_url and not user_agreement_url:
|
||||
await safe_answer_callback(
|
||||
|
||||
@@ -22,7 +22,7 @@ from bot.keyboards.inline.user_keyboards import (
|
||||
get_tariff_packages_keyboard,
|
||||
get_tariff_periods_keyboard,
|
||||
sale_mode_with_callback_context,
|
||||
subscription_options_callback,
|
||||
tariff_purchase_back_callback,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
@@ -303,7 +303,7 @@ async def select_tariff_callback(
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
back_callback=subscription_options_callback(callback_context),
|
||||
back_callback=tariff_purchase_back_callback(callback_context),
|
||||
callback_context=callback_context,
|
||||
)
|
||||
text = _tariff_purchase_text(tariff, current_lang, i18n, settings)
|
||||
@@ -319,7 +319,11 @@ async def select_tariff_callback(
|
||||
|
||||
@router.callback_query(F.data.startswith("tariff:period:"))
|
||||
async def select_tariff_period_callback(
|
||||
callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
@@ -333,7 +337,9 @@ async def select_tariff_period_callback(
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
tariff_key, months_raw = parts[2], parts[3]
|
||||
callback_context = parts[4] if len(parts) > 4 else None
|
||||
callback_tokens = [part for part in parts[4:] if part]
|
||||
callback_context = "bot" if "bot" in callback_tokens else None
|
||||
renew_hwid_devices = "no_hwid" not in callback_tokens
|
||||
tariff = config.require(tariff_key)
|
||||
months = int(months_raw)
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
@@ -343,6 +349,22 @@ async def select_tariff_period_callback(
|
||||
if price_rub is None:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
hwid_renewal_quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
target_tariff_key=tariff.key,
|
||||
months=months,
|
||||
currency=default_currency,
|
||||
)
|
||||
hwid_renewal_stars_quote = (
|
||||
await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
target_tariff_key=tariff.key,
|
||||
months=months,
|
||||
currency="stars",
|
||||
)
|
||||
)
|
||||
markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
@@ -354,6 +376,9 @@ async def select_tariff_period_callback(
|
||||
sale_mode=sale_mode_with_callback_context(f"subscription@{tariff.key}", callback_context),
|
||||
back_callback=f"tariff:select:{tariff.key}{callback_suffix_for_context(callback_context)}",
|
||||
user_id=callback.from_user.id,
|
||||
hwid_renewal_quote=hwid_renewal_quote,
|
||||
hwid_renewal_stars_quote=hwid_renewal_stars_quote,
|
||||
hwid_renewal_selected=bool(renew_hwid_devices),
|
||||
)
|
||||
await callback.message.edit_text(get_text("choose_payment_method"), reply_markup=markup)
|
||||
await callback.answer()
|
||||
@@ -577,7 +602,6 @@ async def hwid_devices_list_callback(
|
||||
if not packages:
|
||||
await callback.answer(get_text("no_hwid_device_packages_available"), show_alert=True)
|
||||
return
|
||||
renewal_available = bool(active.get("device_topup_renewal_available"))
|
||||
markup = get_hwid_device_packages_keyboard(
|
||||
tariff,
|
||||
packages,
|
||||
@@ -585,14 +609,11 @@ async def hwid_devices_list_callback(
|
||||
i18n,
|
||||
settings,
|
||||
back_callback="main_action:my_devices",
|
||||
renewal=renewal_available,
|
||||
)
|
||||
text_key = (
|
||||
"select_hwid_device_renewal_package" if renewal_available else "select_hwid_device_package"
|
||||
renewal=False,
|
||||
)
|
||||
await callback.message.edit_text(
|
||||
get_text(
|
||||
text_key,
|
||||
"select_hwid_device_package",
|
||||
date=active.get("extra_hwid_devices_valid_until_text") or "",
|
||||
),
|
||||
reply_markup=markup,
|
||||
@@ -640,6 +661,7 @@ async def hwid_devices_package_callback(
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
sale_mode_base = "hwid_devices_renewal" if action == "renewal_package" else "hwid_devices"
|
||||
renewal = action == "renewal_package"
|
||||
default_currency = default_currency_key_for_settings(settings)
|
||||
currency_code = default_payment_currency_code_for_settings(settings)
|
||||
currency_quote = await subscription_service.quote_hwid_device_topup(
|
||||
@@ -647,7 +669,7 @@ async def hwid_devices_package_callback(
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=action == "renewal_package",
|
||||
renewal=renewal,
|
||||
currency=default_currency,
|
||||
)
|
||||
stars_quote = await subscription_service.quote_hwid_device_topup(
|
||||
@@ -655,7 +677,7 @@ async def hwid_devices_package_callback(
|
||||
user_id=callback.from_user.id,
|
||||
device_count=count,
|
||||
tariff_key=tariff.key,
|
||||
renewal=action == "renewal_package",
|
||||
renewal=renewal,
|
||||
currency="stars",
|
||||
)
|
||||
if not currency_quote and not stars_quote:
|
||||
|
||||
@@ -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
|
||||
@@ -13,6 +14,13 @@ from config.tariffs_config import (
|
||||
)
|
||||
|
||||
BOT_MENU_CONTEXT = "bot"
|
||||
HWID_RENEWAL_TOKEN = "hwid_renewal"
|
||||
|
||||
|
||||
def sale_mode_tokens(sale_mode: Optional[str]) -> Tuple[str, ...]:
|
||||
if not sale_mode or "|" not in sale_mode:
|
||||
return ()
|
||||
return tuple(token.strip() for token in str(sale_mode).split("|")[1:] if token.strip())
|
||||
|
||||
|
||||
def callback_context_from_back_callback(back_callback: Optional[str]) -> Optional[str]:
|
||||
@@ -23,16 +31,36 @@ def callback_context_from_back_callback(back_callback: Optional[str]) -> Optiona
|
||||
|
||||
def sale_mode_with_callback_context(sale_mode: str, context: Optional[str]) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
if not context or "|" in sale_mode:
|
||||
if not context or context in sale_mode_tokens(sale_mode):
|
||||
return sale_mode
|
||||
return f"{sale_mode}|{context}"
|
||||
|
||||
|
||||
def sale_mode_with_token(sale_mode: str, token: str) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
token = str(token or "").strip()
|
||||
if not token or token in sale_mode_tokens(sale_mode):
|
||||
return sale_mode
|
||||
return f"{sale_mode}|{token}"
|
||||
|
||||
|
||||
def sale_mode_without_token(sale_mode: str, token: str) -> str:
|
||||
sale_mode = sale_mode or "subscription"
|
||||
token = str(token or "").strip()
|
||||
if not token or "|" not in sale_mode:
|
||||
return sale_mode
|
||||
base, *tokens = sale_mode.split("|")
|
||||
kept = [item for item in tokens if item.strip() and item.strip() != token]
|
||||
return "|".join([base, *kept])
|
||||
|
||||
|
||||
def sale_mode_has_token(sale_mode: Optional[str], token: str) -> bool:
|
||||
return str(token or "").strip() in sale_mode_tokens(sale_mode)
|
||||
|
||||
|
||||
def callback_context_from_sale_mode(sale_mode: Optional[str]) -> Optional[str]:
|
||||
if not sale_mode or "|" not in sale_mode:
|
||||
return None
|
||||
context = str(sale_mode).split("|", 1)[1].strip()
|
||||
return context or None
|
||||
tokens = sale_mode_tokens(sale_mode)
|
||||
return BOT_MENU_CONTEXT if BOT_MENU_CONTEXT in tokens else None
|
||||
|
||||
|
||||
def callback_suffix_for_context(context: Optional[str]) -> str:
|
||||
@@ -43,6 +71,12 @@ def subscription_options_callback(context: Optional[str]) -> str:
|
||||
return "main_action:bot_subscribe" if context == BOT_MENU_CONTEXT else "main_action:subscribe"
|
||||
|
||||
|
||||
def tariff_purchase_back_callback(context: Optional[str]) -> str:
|
||||
if context == BOT_MENU_CONTEXT:
|
||||
return "main_action:bot_interface"
|
||||
return subscription_options_callback(context)
|
||||
|
||||
|
||||
def payment_methods_back_callback(
|
||||
value: str, sale_mode: str = "subscription", price: Optional[float] = None
|
||||
) -> str:
|
||||
@@ -138,8 +172,7 @@ def get_main_menu_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
||||
)
|
||||
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_info_button"), callback_data="main_action:info")
|
||||
)
|
||||
@@ -206,8 +239,7 @@ def get_bot_interface_inline_keyboard(
|
||||
InlineKeyboardButton(text=_(key="menu_support_button"), url=settings.SUPPORT_LINK)
|
||||
)
|
||||
|
||||
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
|
||||
if settings.PRIVACY_POLICY_URL or user_agreement_url:
|
||||
if settings.PRIVACY_POLICY_URL or settings.USER_AGREEMENT_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_info_button"), callback_data="main_action:bot_info"
|
||||
@@ -483,6 +515,9 @@ def get_payment_method_keyboard(
|
||||
back_callback: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
is_admin: Optional[bool] = None,
|
||||
hwid_renewal_quote: Optional[Dict[str, Any]] = None,
|
||||
hwid_renewal_stars_quote: Optional[Dict[str, Any]] = None,
|
||||
hwid_renewal_selected: bool = True,
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -491,12 +526,39 @@ def get_payment_method_keyboard(
|
||||
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
||||
|
||||
value_str = _format_value(months)
|
||||
import logging as _kbd_logging
|
||||
|
||||
_kbd_logging.info(
|
||||
"payment_method_keyboard build: order=%s",
|
||||
settings.payment_methods_order,
|
||||
)
|
||||
payment_sale_mode = sale_mode
|
||||
selected_hwid_quote = hwid_renewal_quote or hwid_renewal_stars_quote
|
||||
if selected_hwid_quote:
|
||||
tariff_key = None
|
||||
sale_mode_main = str(sale_mode or "").split("|", 1)[0]
|
||||
if "@" in sale_mode_main:
|
||||
tariff_key = sale_mode_main.split("@", 1)[1]
|
||||
context = callback_context_from_sale_mode(sale_mode)
|
||||
toggle_tokens = [f"tariff:period:{tariff_key}:{value_str}"]
|
||||
if context:
|
||||
toggle_tokens.append(context)
|
||||
toggle_tokens.append("no_hwid" if hwid_renewal_selected else "hwid")
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(
|
||||
"payment_hwid_renewal_toggle_on"
|
||||
if hwid_renewal_selected
|
||||
else "payment_hwid_renewal_toggle_off",
|
||||
count=int(selected_hwid_quote.get("device_count") or 0),
|
||||
price=(
|
||||
hwid_renewal_quote.get("price")
|
||||
if hwid_renewal_quote
|
||||
else hwid_renewal_stars_quote.get("price")
|
||||
),
|
||||
currency_symbol=currency_symbol_val,
|
||||
),
|
||||
callback_data=":".join(toggle_tokens),
|
||||
)
|
||||
)
|
||||
if hwid_renewal_selected:
|
||||
payment_sale_mode = sale_mode_with_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||
else:
|
||||
payment_sale_mode = sale_mode_without_token(sale_mode, HWID_RENEWAL_TOKEN)
|
||||
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
|
||||
|
||||
for method in settings.payment_methods_order:
|
||||
@@ -504,7 +566,7 @@ def get_payment_method_keyboard(
|
||||
if (
|
||||
not spec
|
||||
or not spec.callback_prefix
|
||||
or not spec.is_usable_for_payment_currency(settings, currency_symbol_val)
|
||||
or not spec.is_usable_for_payment(settings, currency_symbol_val, price)
|
||||
or not spec.is_available_to_user(
|
||||
settings,
|
||||
user_id=user_id,
|
||||
@@ -517,7 +579,7 @@ def get_payment_method_keyboard(
|
||||
value=value_str,
|
||||
rub_price=price,
|
||||
stars_price=stars_price,
|
||||
sale_mode=sale_mode,
|
||||
sale_mode=payment_sale_mode,
|
||||
)
|
||||
if not callback_data:
|
||||
continue
|
||||
@@ -576,7 +638,7 @@ def get_yk_autopay_choice_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}{suffix}",
|
||||
callback_data=f"pay_yk_saved_list:{value_str}:{price_str}:0{suffix}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
@@ -718,10 +780,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
|
||||
|
||||
|
||||
+19
-5
@@ -91,12 +91,9 @@ async def register_all_routers(dp: Dispatcher, settings: Settings):
|
||||
logging.info("All application routers registered.")
|
||||
|
||||
|
||||
async def on_startup_configured(dispatcher: Dispatcher):
|
||||
async def configure_telegram_webhook(dispatcher: Dispatcher) -> None:
|
||||
bot: Bot = dispatcher["bot_instance"]
|
||||
settings: Settings = dispatcher["settings"]
|
||||
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
|
||||
|
||||
logging.info("STARTUP: on_startup_configured executing...")
|
||||
|
||||
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
||||
if telegram_webhook_url_to_set:
|
||||
@@ -152,6 +149,14 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
)
|
||||
raise SystemExit("WEBHOOK_BASE_URL is required. Polling mode is disabled.")
|
||||
|
||||
|
||||
async def on_startup_configured(dispatcher: Dispatcher):
|
||||
bot: Bot = dispatcher["bot_instance"]
|
||||
settings: Settings = dispatcher["settings"]
|
||||
i18n_instance: JsonI18n = dispatcher["i18n_instance"]
|
||||
|
||||
logging.info("STARTUP: on_startup_configured executing...")
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
|
||||
async def _configure_mini_app_menu() -> None:
|
||||
@@ -331,8 +336,17 @@ async def run_bot(settings_param: Settings):
|
||||
_yk_path,
|
||||
)
|
||||
|
||||
async def _after_webhooks_started() -> None:
|
||||
await configure_telegram_webhook(dp)
|
||||
|
||||
async def web_server_task():
|
||||
await build_and_start_web_app(dp, bot, settings_param, local_async_session_factory)
|
||||
await build_and_start_web_app(
|
||||
dp,
|
||||
bot,
|
||||
settings_param,
|
||||
local_async_session_factory,
|
||||
after_webhooks_started=_after_webhooks_started,
|
||||
)
|
||||
|
||||
main_tasks = [asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")]
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ class ActionLoggerMiddleware(BaseMiddleware):
|
||||
|
||||
result = await handler(event, data)
|
||||
|
||||
if data.get("skip_action_log") or data.get("antiflood_dropped"):
|
||||
return result
|
||||
|
||||
session: AsyncSession = data["session"]
|
||||
event_user: Optional[User] = data.get("event_from_user")
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Deque, Dict, Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Update
|
||||
|
||||
from bot.infra.redis import get_redis, redis_key
|
||||
from config.settings import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_WINDOW_SECONDS = 60
|
||||
DEFAULT_MAX_UPDATES_PER_WINDOW = 180
|
||||
DEFAULT_MESSAGE_MAX_PER_WINDOW = 120
|
||||
DEFAULT_CALLBACK_MAX_PER_WINDOW = 240
|
||||
DEFAULT_INLINE_MAX_PER_WINDOW = 60
|
||||
DEFAULT_START_MAX_PER_WINDOW = 30
|
||||
DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW = 60
|
||||
DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS = 20
|
||||
DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS = 30
|
||||
|
||||
EXPENSIVE_CALLBACK_PREFIXES = (
|
||||
"pay_",
|
||||
"trial_action:confirm_activate",
|
||||
"main_action:request_trial",
|
||||
"main_action:apply_promo",
|
||||
"main_action:bot_apply_promo",
|
||||
"tariff_change:apply:",
|
||||
"tariff_change:confirm_pay:",
|
||||
"tariff_change:pay:",
|
||||
"autorenew:confirm:",
|
||||
"disconnect_device:",
|
||||
)
|
||||
|
||||
TRIAL_CALLBACK_PREFIXES = (
|
||||
"trial_action:confirm_activate",
|
||||
"main_action:request_trial",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RateLimitRule:
|
||||
window_seconds: int
|
||||
max_events: int
|
||||
|
||||
|
||||
class UpdateAntiFloodMiddleware(BaseMiddleware):
|
||||
"""Drop extreme update floods before DB-backed middleware runs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
*,
|
||||
default_rule: Optional[RateLimitRule] = None,
|
||||
action_rules: Optional[Dict[str, RateLimitRule]] = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.settings = settings
|
||||
self.default_rule = default_rule or RateLimitRule(
|
||||
window_seconds=int(
|
||||
getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS)
|
||||
or DEFAULT_WINDOW_SECONDS
|
||||
),
|
||||
max_events=int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW",
|
||||
DEFAULT_MAX_UPDATES_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_MAX_UPDATES_PER_WINDOW
|
||||
),
|
||||
)
|
||||
self.action_rules = action_rules or _default_action_rules(settings)
|
||||
self._local_buckets: Dict[str, Deque[float]] = defaultdict(deque)
|
||||
self._local_cooldowns: Dict[str, float] = {}
|
||||
self._local_lock = asyncio.Lock()
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
if bool(getattr(self.settings, "TELEGRAM_DROP_NON_PRIVATE_UPDATES", True)):
|
||||
chat_type = _message_or_callback_chat_type(event)
|
||||
if chat_type is not None and chat_type != "private":
|
||||
logger.info(
|
||||
"Telegram update dropped outside private chat: chat_type=%s update_type=%s",
|
||||
chat_type,
|
||||
getattr(event, "event_type", "unknown"),
|
||||
)
|
||||
_mark_dropped(data)
|
||||
return None
|
||||
|
||||
if not bool(getattr(self.settings, "TELEGRAM_ANTIFLOOD_ENABLED", True)):
|
||||
return await handler(event, data)
|
||||
|
||||
actor_key = _update_actor_key(event)
|
||||
if not actor_key:
|
||||
return await handler(event, data)
|
||||
|
||||
action_key = _update_action_key(event)
|
||||
cooldown = _update_action_cooldown(event, self.settings)
|
||||
if cooldown and await self._is_cooldown_active(cooldown[0], cooldown[1]):
|
||||
logger.info(
|
||||
"Telegram callback dropped by action cooldown: actor=%s cooldown=%s",
|
||||
actor_key,
|
||||
cooldown[0],
|
||||
)
|
||||
_mark_dropped(data)
|
||||
await _quietly_answer_callback(event)
|
||||
return None
|
||||
|
||||
if await self._is_limited("updates", actor_key, self.default_rule) or (
|
||||
action_key
|
||||
and action_key in self.action_rules
|
||||
and await self._is_limited(action_key, actor_key, self.action_rules[action_key])
|
||||
):
|
||||
logger.warning(
|
||||
"Telegram update dropped by anti-flood: actor=%s update_type=%s",
|
||||
actor_key,
|
||||
action_key or getattr(event, "event_type", "unknown"),
|
||||
)
|
||||
_mark_dropped(data)
|
||||
return None
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
async def _is_limited(self, bucket_name: str, actor_key: str, rule: RateLimitRule) -> bool:
|
||||
if rule.window_seconds <= 0 or rule.max_events <= 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
redis = await get_redis(self.settings)
|
||||
if redis is not None:
|
||||
key = redis_key(
|
||||
self.settings,
|
||||
"rate-limit",
|
||||
"telegram",
|
||||
bucket_name,
|
||||
actor_key,
|
||||
)
|
||||
current = int(await redis.incr(key))
|
||||
if current == 1:
|
||||
await redis.expire(key, rule.window_seconds)
|
||||
return current > rule.max_events
|
||||
except Exception as exc:
|
||||
logger.warning("Redis telegram anti-flood unavailable; using local fallback: %s", exc)
|
||||
|
||||
return await self._is_limited_local(f"{bucket_name}:{actor_key}", rule)
|
||||
|
||||
async def _is_cooldown_active(self, cooldown_key: str, ttl_seconds: int) -> bool:
|
||||
if ttl_seconds <= 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
redis = await get_redis(self.settings)
|
||||
if redis is not None:
|
||||
key = redis_key(
|
||||
self.settings,
|
||||
"cooldown",
|
||||
"telegram",
|
||||
cooldown_key,
|
||||
)
|
||||
acquired = await redis.set(key, "1", nx=True, ex=ttl_seconds)
|
||||
return not bool(acquired)
|
||||
except Exception as exc:
|
||||
logger.warning("Redis telegram cooldown unavailable; using local fallback: %s", exc)
|
||||
|
||||
return await self._is_cooldown_active_local(cooldown_key, ttl_seconds)
|
||||
|
||||
async def _is_cooldown_active_local(self, cooldown_key: str, ttl_seconds: int) -> bool:
|
||||
now = time.monotonic()
|
||||
async with self._local_lock:
|
||||
expired = [
|
||||
key for key, expires_at in self._local_cooldowns.items() if expires_at <= now
|
||||
]
|
||||
for key in expired:
|
||||
self._local_cooldowns.pop(key, None)
|
||||
|
||||
expires_at = self._local_cooldowns.get(cooldown_key)
|
||||
if expires_at and expires_at > now:
|
||||
return True
|
||||
|
||||
self._local_cooldowns[cooldown_key] = now + ttl_seconds
|
||||
return False
|
||||
|
||||
async def _is_limited_local(self, actor_key: str, rule: RateLimitRule) -> bool:
|
||||
now = time.monotonic()
|
||||
cutoff = now - rule.window_seconds
|
||||
async with self._local_lock:
|
||||
bucket = self._local_buckets[actor_key]
|
||||
while bucket and bucket[0] <= cutoff:
|
||||
bucket.popleft()
|
||||
bucket.append(now)
|
||||
if len(bucket) > rule.max_events:
|
||||
return True
|
||||
if not bucket:
|
||||
self._local_buckets.pop(actor_key, None)
|
||||
return False
|
||||
|
||||
|
||||
def _update_actor_key(update: Update) -> Optional[str]:
|
||||
user_id = None
|
||||
chat_id = None
|
||||
|
||||
if update.message:
|
||||
user_id = update.message.from_user.id if update.message.from_user else None
|
||||
chat_id = update.message.chat.id if update.message.chat else None
|
||||
elif update.callback_query:
|
||||
user_id = update.callback_query.from_user.id if update.callback_query.from_user else None
|
||||
if update.callback_query.message and update.callback_query.message.chat:
|
||||
chat_id = update.callback_query.message.chat.id
|
||||
elif update.inline_query:
|
||||
user_id = update.inline_query.from_user.id if update.inline_query.from_user else None
|
||||
|
||||
if user_id is not None:
|
||||
return f"user:{int(user_id)}"
|
||||
if chat_id is not None:
|
||||
return f"chat:{int(chat_id)}"
|
||||
return None
|
||||
|
||||
|
||||
def _message_or_callback_chat_type(update: Update) -> Optional[str]:
|
||||
if update.message and update.message.chat:
|
||||
return str(update.message.chat.type)
|
||||
if (
|
||||
update.callback_query
|
||||
and update.callback_query.message
|
||||
and update.callback_query.message.chat
|
||||
):
|
||||
return str(update.callback_query.message.chat.type)
|
||||
return None
|
||||
|
||||
|
||||
def _update_action_key(update: Update) -> str:
|
||||
if update.message:
|
||||
text = update.message.text or ""
|
||||
if text.startswith("/start"):
|
||||
return "start"
|
||||
return "message"
|
||||
if update.callback_query:
|
||||
data = update.callback_query.data or ""
|
||||
if data.startswith(EXPENSIVE_CALLBACK_PREFIXES):
|
||||
return "expensive_callback"
|
||||
return "callback"
|
||||
if update.inline_query:
|
||||
return "inline"
|
||||
return "updates"
|
||||
|
||||
|
||||
def _update_action_cooldown(update: Update, settings: Settings) -> Optional[tuple[str, int]]:
|
||||
if not bool(getattr(settings, "TELEGRAM_ACTION_COOLDOWN_ENABLED", True)):
|
||||
return None
|
||||
if not update.callback_query or not update.callback_query.from_user:
|
||||
return None
|
||||
|
||||
callback_data = update.callback_query.data or ""
|
||||
if not callback_data:
|
||||
return None
|
||||
|
||||
user_id = int(update.callback_query.from_user.id)
|
||||
data_digest = hashlib.sha256(callback_data.encode("utf-8")).hexdigest()[:24]
|
||||
|
||||
if callback_data.startswith("pay_"):
|
||||
ttl = int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS",
|
||||
DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS,
|
||||
)
|
||||
or DEFAULT_PAYMENT_CALLBACK_COOLDOWN_SECONDS
|
||||
)
|
||||
return f"payment:user:{user_id}:data:{data_digest}", ttl
|
||||
|
||||
if callback_data.startswith(TRIAL_CALLBACK_PREFIXES):
|
||||
ttl = int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS",
|
||||
DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS,
|
||||
)
|
||||
or DEFAULT_TRIAL_CALLBACK_COOLDOWN_SECONDS
|
||||
)
|
||||
return f"trial:user:{user_id}:data:{data_digest}", ttl
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _quietly_answer_callback(update: Update) -> None:
|
||||
callback = update.callback_query
|
||||
if not callback:
|
||||
return
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _mark_dropped(data: Dict[str, Any]) -> None:
|
||||
data["antiflood_dropped"] = True
|
||||
data["skip_action_log"] = True
|
||||
|
||||
|
||||
def _default_action_rules(settings: Settings) -> Dict[str, RateLimitRule]:
|
||||
window_seconds = int(
|
||||
getattr(settings, "TELEGRAM_ANTIFLOOD_WINDOW_SECONDS", DEFAULT_WINDOW_SECONDS)
|
||||
or DEFAULT_WINDOW_SECONDS
|
||||
)
|
||||
return {
|
||||
"message": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW",
|
||||
DEFAULT_MESSAGE_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_MESSAGE_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
"callback": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW",
|
||||
DEFAULT_CALLBACK_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_CALLBACK_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
"inline": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW",
|
||||
DEFAULT_INLINE_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_INLINE_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
"start": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW",
|
||||
DEFAULT_START_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_START_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
"expensive_callback": RateLimitRule(
|
||||
window_seconds,
|
||||
int(
|
||||
getattr(
|
||||
settings,
|
||||
"TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW",
|
||||
DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW,
|
||||
)
|
||||
or DEFAULT_EXPENSIVE_CALLBACK_MAX_PER_WINDOW
|
||||
),
|
||||
),
|
||||
}
|
||||
@@ -114,6 +114,7 @@ class WebAppPaymentContext:
|
||||
sale_mode: str
|
||||
currency: str = "RUB"
|
||||
traffic_gb: Optional[float] = None
|
||||
hwid_device_count: Optional[int] = None
|
||||
hwid_valid_from: Optional[Any] = None
|
||||
hwid_valid_until: Optional[Any] = None
|
||||
hwid_pricing_period_months: Optional[int] = None
|
||||
@@ -126,7 +127,10 @@ ServiceFactory = Callable[[ServiceFactoryContext], Any]
|
||||
WebhookPathGetter = Callable[[Any], str]
|
||||
WebhookRoute = Callable[[Any], Awaitable[Any]]
|
||||
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
|
||||
ReusableWebAppPaymentResolver = Callable[[WebAppPaymentContext, Any], Awaitable[Optional[str]]]
|
||||
CurrencySupportResolver = Callable[[Any], Optional[Sequence[str]]]
|
||||
PaymentAmountResolver = Callable[[Any, Any, Any], bool]
|
||||
PaymentMinimumResolver = Callable[[Any, Any], Optional[Mapping[str, Any]]]
|
||||
|
||||
|
||||
def normalize_payment_currency_code(value: Any, default: str = "RUB") -> str:
|
||||
@@ -177,6 +181,7 @@ class PaymentProviderSpec:
|
||||
webhook_route: Optional[WebhookRoute] = None
|
||||
webhook_requires_base_url: bool = False
|
||||
create_webapp_payment: Optional[WebAppPaymentFactory] = None
|
||||
reuse_webapp_payment: Optional[ReusableWebAppPaymentResolver] = None
|
||||
requires_configured_service: bool = True
|
||||
price_source: str = "rub"
|
||||
emoji: str = "💳"
|
||||
@@ -191,6 +196,8 @@ class PaymentProviderSpec:
|
||||
admin_only_enabled: Optional[EnabledPredicate] = None
|
||||
supported_currencies: Optional[Sequence[str]] = ("RUB",)
|
||||
supported_currencies_resolver: Optional[CurrencySupportResolver] = None
|
||||
payment_amount_resolver: Optional[PaymentAmountResolver] = None
|
||||
payment_minimum_resolver: Optional[PaymentMinimumResolver] = None
|
||||
currency_support_note: str = ""
|
||||
currency_support_url: Optional[str] = None
|
||||
|
||||
@@ -305,6 +312,30 @@ class PaymentProviderSpec:
|
||||
return True
|
||||
return self.supports_currency(source, currency)
|
||||
|
||||
def payment_minimum(self, source: Any, currency: Any) -> Optional[Mapping[str, Any]]:
|
||||
if self.payment_minimum_resolver is None:
|
||||
return None
|
||||
source_for_amount = self._currency_source(source)
|
||||
try:
|
||||
return self.payment_minimum_resolver(source_for_amount, currency)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def is_usable_for_payment_amount(self, source: Any, currency: Any, amount: Any) -> bool:
|
||||
if self.price_source == "stars" or self.payment_amount_resolver is None:
|
||||
return True
|
||||
source_for_amount = self._currency_source(source)
|
||||
try:
|
||||
return bool(self.payment_amount_resolver(source_for_amount, currency, amount))
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def is_usable_for_payment(self, source: Any, currency: Any, amount: Any) -> bool:
|
||||
return self.is_usable_for_payment_currency(
|
||||
source,
|
||||
currency,
|
||||
) and self.is_usable_for_payment_amount(source, currency, amount)
|
||||
|
||||
def is_visible(self, source: Any, app: Any) -> bool:
|
||||
return self.is_enabled(source) and self.is_service_configured(app)
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ class CryptoPayService:
|
||||
sale_mode: str = "subscription",
|
||||
url_kind: str = "bot",
|
||||
hwid_quote: Optional[dict] = None,
|
||||
hwid_device_count: Optional[int] = None,
|
||||
currency: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
@@ -210,7 +211,11 @@ class CryptoPayService:
|
||||
return None
|
||||
|
||||
sale_base = sale_mode_base(sale_mode)
|
||||
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
|
||||
amounts = payment_record_amounts(
|
||||
months=months,
|
||||
sale_mode=sale_mode,
|
||||
hwid_device_count=hwid_device_count,
|
||||
)
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
@@ -252,6 +257,7 @@ class CryptoPayService:
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
"sale_mode": sale_mode,
|
||||
"traffic_gb": str(months) if sale_mode_is_traffic(sale_mode) else None,
|
||||
"hwid_devices": amounts.purchased_hwid_devices,
|
||||
}
|
||||
)
|
||||
try:
|
||||
@@ -513,6 +519,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
}
|
||||
if ctx.hwid_valid_from and ctx.hwid_valid_until
|
||||
else None,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
if not url:
|
||||
return payment_failed()
|
||||
|
||||
@@ -53,11 +53,14 @@ from .shared import (
|
||||
notify_service_unavailable,
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
render_payment_link,
|
||||
safe_callback_answer,
|
||||
)
|
||||
|
||||
_LOG = "freekassa"
|
||||
@@ -76,7 +79,7 @@ class FreeKassaConfig(ProviderEnvConfig):
|
||||
MERCHANT_ID: Optional[str] = None
|
||||
FIRST_SECRET: Optional[str] = None
|
||||
SECOND_SECRET: Optional[str] = None
|
||||
PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
||||
PAYMENT_URL: str = Field(default="https://pay.freekassa.net/")
|
||||
API_KEY: Optional[str] = None
|
||||
PAYMENT_IP: Optional[str] = None
|
||||
PAYMENT_METHOD_ID: Optional[int] = None
|
||||
@@ -153,7 +156,7 @@ class FreeKassaService(HttpClientMixin):
|
||||
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
|
||||
|
||||
self.api_base_url: str = "https://api.fk.life/v1"
|
||||
self._init_http_client(total_timeout=15)
|
||||
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
|
||||
self._nonce_lock = asyncio.Lock()
|
||||
self._last_nonce = int(time.time() * 1000)
|
||||
|
||||
@@ -253,6 +256,62 @@ class FreeKassaService(HttpClientMixin):
|
||||
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success",
|
||||
)
|
||||
|
||||
async def get_orders(
|
||||
self,
|
||||
*,
|
||||
payment_id: int,
|
||||
order_status: Optional[int] = None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"shopId": int(self.shop_id),
|
||||
"nonce": await self._generate_nonce(),
|
||||
"paymentId": str(payment_id),
|
||||
}
|
||||
if order_status is not None:
|
||||
payload["orderStatus"] = int(order_status)
|
||||
payload["signature"] = self._sign_payload(payload)
|
||||
|
||||
session = await self._get_session()
|
||||
return await post_json_request(
|
||||
session,
|
||||
f"{self.api_base_url}/orders",
|
||||
body=payload,
|
||||
log_prefix="FreeKassa get_orders",
|
||||
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success",
|
||||
)
|
||||
|
||||
async def try_reuse_pending_order(self, payment: Any) -> Optional[str]:
|
||||
order_hash = str(getattr(payment, "provider_payment_id", None) or "").strip()
|
||||
if not order_hash:
|
||||
return None
|
||||
|
||||
success, response_data = await self.get_orders(
|
||||
payment_id=payment.payment_id,
|
||||
order_status=0,
|
||||
)
|
||||
if not success:
|
||||
return None
|
||||
|
||||
for order in response_data.get("orders") or []:
|
||||
if not isinstance(order, dict):
|
||||
continue
|
||||
try:
|
||||
is_new = int(order.get("status", -1)) == 0
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not is_new:
|
||||
continue
|
||||
if str(order.get("merchant_order_id") or "") != str(payment.payment_id):
|
||||
continue
|
||||
fk_order_id = str(order.get("fk_order_id") or "").strip()
|
||||
if fk_order_id:
|
||||
payment_url = (self.config.PAYMENT_URL or "https://pay.freekassa.net/").rstrip("/")
|
||||
return f"{payment_url}/form/{fk_order_id}/{order_hash}"
|
||||
return None
|
||||
|
||||
async def _generate_nonce(self) -> int:
|
||||
async with self._nonce_lock:
|
||||
candidate = int(time.time() * 1000)
|
||||
@@ -513,6 +572,39 @@ async def pay_fk_callback_handler(
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
reuse_amounts = payment_record_amounts(
|
||||
months=parts.months,
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
)
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
provider="freekassa",
|
||||
pending_status="pending_freekassa",
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
sale_mode=parts.sale_mode,
|
||||
months=reuse_amounts.months,
|
||||
purchased_gb=reuse_amounts.purchased_gb,
|
||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||
tariff_key=reuse_amounts.tariff_key,
|
||||
)
|
||||
if reusable_payment is not None:
|
||||
reusable_url = await freekassa_service.try_reuse_pending_order(reusable_payment)
|
||||
if reusable_url:
|
||||
await safe_callback_answer(callback)
|
||||
await render_payment_link(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
payment_url=reusable_url,
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||
await session.commit()
|
||||
@@ -625,6 +717,14 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
|
||||
service: FreeKassaService = ctx.request.app.get("freekassa_service")
|
||||
if not service or not service.configured:
|
||||
return None
|
||||
|
||||
return await service.try_reuse_pending_order(payment)
|
||||
|
||||
|
||||
_PRESENTATION_MANIFEST = tuple(
|
||||
ProviderManifestField(
|
||||
key=key,
|
||||
@@ -723,7 +823,7 @@ _CONFIG_MANIFEST = (
|
||||
"FREEKASSA_PAYMENT_URL",
|
||||
"url",
|
||||
"Payment URL",
|
||||
placeholder="https://pay.freekassa.ru/",
|
||||
placeholder="https://pay.freekassa.net/",
|
||||
subsection="FreeKassa",
|
||||
attr="PAYMENT_URL",
|
||||
),
|
||||
@@ -772,6 +872,7 @@ SPEC = PaymentProviderSpec(
|
||||
webhook_path=lambda source: "/webhook/freekassa",
|
||||
webhook_route=freekassa_webhook_route,
|
||||
create_webapp_payment=create_webapp_payment,
|
||||
reuse_webapp_payment=reuse_webapp_payment,
|
||||
config_class=FreeKassaConfig,
|
||||
presentation_class=FreeKassaPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -54,10 +55,12 @@ from .shared import (
|
||||
notify_user_payment_failed,
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
render_payment_link,
|
||||
)
|
||||
|
||||
router = Router(name="user_subscription_payments_heleket_router")
|
||||
@@ -243,7 +246,7 @@ class HeleketService(HttpClientMixin):
|
||||
self.referral_service = referral_service
|
||||
self._default_return_url = default_return_url
|
||||
|
||||
self._init_http_client(total_timeout=20)
|
||||
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
|
||||
if not self.configured:
|
||||
logging.warning(
|
||||
"HeleketService initialized but not fully configured. Payments disabled."
|
||||
@@ -372,6 +375,70 @@ class HeleketService(HttpClientMixin):
|
||||
logging.exception("Heleket create_payment_link: request failed.")
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def get_payment_info(self, payment_uuid: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
payment_uuid = str(payment_uuid or "").strip()
|
||||
if not payment_uuid:
|
||||
return False, {"message": "missing_payment_uuid"}
|
||||
|
||||
body = {"uuid": payment_uuid}
|
||||
headers = {
|
||||
"merchant": self.merchant_id,
|
||||
"sign": _compute_signature(body, self.api_key),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
session = await self._get_session()
|
||||
try:
|
||||
async with session.post(
|
||||
f"{self.base_url}/v1/payment/info",
|
||||
data=_serialize_for_signature(body).encode("utf-8"),
|
||||
headers=headers,
|
||||
) as response:
|
||||
response_data = await response.json(content_type=None)
|
||||
state = response_data.get("state") if isinstance(response_data, dict) else None
|
||||
if response.status != 200 or state != 0:
|
||||
logging.warning(
|
||||
"Heleket get_payment_info failed: uuid=%s status=%s body=%s",
|
||||
payment_uuid,
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
result = response_data.get("result") or {}
|
||||
return isinstance(result, dict), result
|
||||
except Exception as exc:
|
||||
logging.exception("Heleket get_payment_info request failed: uuid=%s", payment_uuid)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]:
|
||||
payment_uuid = str(getattr(payment, "provider_payment_id", None) or "").strip()
|
||||
if not payment_uuid:
|
||||
return None
|
||||
|
||||
success, data = await self.get_payment_info(payment_uuid)
|
||||
if not success or not isinstance(data, dict):
|
||||
return None
|
||||
status = str(data.get("payment_status") or data.get("status") or "").lower()
|
||||
if status != "check" or bool(data.get("is_final")):
|
||||
return None
|
||||
if str(data.get("uuid") or "") != payment_uuid:
|
||||
return None
|
||||
if str(data.get("order_id") or "") != str(payment.payment_id):
|
||||
return None
|
||||
try:
|
||||
expired_at = int(data.get("expired_at") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if expired_at and expired_at <= int(time.time()):
|
||||
return None
|
||||
return (
|
||||
str(data.get("url") or "").strip()
|
||||
or str(getattr(payment, "provider_payment_url", None) or "").strip()
|
||||
or None
|
||||
)
|
||||
|
||||
def _verify_signature(self, payload: Dict[str, Any]) -> bool:
|
||||
received = payload.get("sign")
|
||||
if not isinstance(received, str) or not received:
|
||||
@@ -612,6 +679,38 @@ async def pay_heleket_callback_handler(
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
reuse_amounts = payment_record_amounts(
|
||||
months=parts.months,
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
)
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
provider="heleket",
|
||||
pending_status="pending_heleket",
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
sale_mode=parts.sale_mode,
|
||||
months=reuse_amounts.months,
|
||||
purchased_gb=reuse_amounts.purchased_gb,
|
||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||
tariff_key=reuse_amounts.tariff_key,
|
||||
)
|
||||
if reusable_payment is not None:
|
||||
reusable_url = await heleket_service.try_reuse_pending_payment(reusable_payment)
|
||||
if reusable_url:
|
||||
await render_payment_link(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
payment_url=reusable_url,
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||
await session.commit()
|
||||
@@ -684,6 +783,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
|
||||
service: HeleketService = ctx.request.app.get("heleket_service")
|
||||
if not service or not service.configured:
|
||||
return None
|
||||
return await service.try_reuse_pending_payment(payment)
|
||||
|
||||
|
||||
async def heleket_webhook_route(request: web.Request) -> web.Response:
|
||||
service: HeleketService = request.app["heleket_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -887,6 +993,7 @@ SPEC = PaymentProviderSpec(
|
||||
webhook_path=lambda source: "/webhook/heleket",
|
||||
webhook_route=heleket_webhook_route,
|
||||
create_webapp_payment=create_webapp_payment,
|
||||
reuse_webapp_payment=reuse_webapp_payment,
|
||||
emoji="🪙",
|
||||
config_class=HeleketConfig,
|
||||
presentation_class=HeleketPresentation,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,7 @@ from .shared import (
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
render_payment_link,
|
||||
safe_callback_answer,
|
||||
)
|
||||
|
||||
@@ -157,7 +158,7 @@ class PlategaService(HttpClientMixin):
|
||||
self.referral_service = referral_service
|
||||
self._default_return_url = default_return_url
|
||||
|
||||
self._init_http_client(total_timeout=20)
|
||||
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
|
||||
if not self.configured:
|
||||
logging.warning(
|
||||
"PlategaService initialized but not fully configured. Payments disabled."
|
||||
@@ -282,6 +283,69 @@ class PlategaService(HttpClientMixin):
|
||||
log_prefix="Platega create_transaction",
|
||||
)
|
||||
|
||||
async def get_transaction(self, transaction_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
transaction_id = str(transaction_id or "").strip()
|
||||
if not transaction_id:
|
||||
return False, {"message": "missing_transaction_id"}
|
||||
|
||||
session = await self._get_session()
|
||||
try:
|
||||
async with session.get(
|
||||
f"{self.base_url}/transaction/{transaction_id}",
|
||||
headers=self._auth_headers,
|
||||
) as response:
|
||||
data = await response.json(content_type=None)
|
||||
if response.status != 200 or not isinstance(data, dict):
|
||||
logging.warning(
|
||||
"Platega get_transaction failed: id=%s status=%s body=%s",
|
||||
transaction_id,
|
||||
response.status,
|
||||
data,
|
||||
)
|
||||
return False, {"status": response.status, "message": data}
|
||||
return True, data
|
||||
except Exception as exc:
|
||||
logging.exception("Platega get_transaction request failed: id=%s", transaction_id)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def try_reuse_pending_transaction(
|
||||
self,
|
||||
payment: Any,
|
||||
*,
|
||||
user_id: int,
|
||||
sale_mode: str,
|
||||
variant: str,
|
||||
) -> Optional[str]:
|
||||
transaction_id = str(getattr(payment, "provider_payment_id", None) or "").strip()
|
||||
payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip()
|
||||
if not transaction_id or not payment_url:
|
||||
return None
|
||||
|
||||
success, data = await self.get_transaction(transaction_id)
|
||||
if not success or str(data.get("status") or "").upper() != "PENDING":
|
||||
return None
|
||||
if str(data.get("id") or "") != transaction_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(str(data.get("payload") or ""))
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
expected = {
|
||||
"payment_db_id": str(payment.payment_id),
|
||||
"user_id": str(user_id),
|
||||
"sale_mode": str(sale_mode),
|
||||
"platega_variant": str(variant),
|
||||
}
|
||||
if not isinstance(payload, dict) or any(
|
||||
str(payload.get(key) or "") != value for key, value in expected.items()
|
||||
):
|
||||
return None
|
||||
return payment_url
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="platega_disabled")
|
||||
@@ -519,6 +583,43 @@ async def pay_platega_callback_handler(
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
reuse_amounts = payment_record_amounts(
|
||||
months=parts.months,
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
)
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
provider="platega",
|
||||
pending_status="pending_platega",
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
sale_mode=parts.sale_mode,
|
||||
months=reuse_amounts.months,
|
||||
purchased_gb=reuse_amounts.purchased_gb,
|
||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||
tariff_key=reuse_amounts.tariff_key,
|
||||
)
|
||||
if reusable_payment is not None:
|
||||
reusable_url = await platega_service.try_reuse_pending_transaction(
|
||||
reusable_payment,
|
||||
user_id=callback.from_user.id,
|
||||
sale_mode=parts.sale_mode,
|
||||
variant=platega_variant,
|
||||
)
|
||||
if reusable_url:
|
||||
await render_payment_link(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
payment_url=reusable_url,
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||
await session.commit()
|
||||
@@ -549,7 +650,6 @@ async def pay_platega_callback_handler(
|
||||
)
|
||||
transaction_id = first_value(response_data, "transactionId", "id")
|
||||
redirect_url = first_value(response_data, "redirect", "url", "paymentUrl")
|
||||
provider_status = str((response_data or {}).get("status") or payment_record.status)
|
||||
# Platega requires *both* a transaction id and a redirect url to count as a
|
||||
# usable payment — neither field is sufficient on its own. Skipping the
|
||||
# persistence step when the redirect is missing matches the pre-refactor
|
||||
@@ -566,7 +666,6 @@ async def pay_platega_callback_handler(
|
||||
api_success=success,
|
||||
payment_url=redirect_url,
|
||||
provider_payment_id=persistable_id,
|
||||
new_status=provider_status if persistable_id else None,
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
|
||||
@@ -609,6 +708,7 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
@@ -651,7 +751,6 @@ async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web
|
||||
first_value(response_data, "redirect", "url", "paymentUrl") if success else None
|
||||
),
|
||||
provider_payment_id=first_value(response_data, "transactionId", "id"),
|
||||
new_status=str((response_data or {}).get("status") or payment.status),
|
||||
log_prefix="Platega",
|
||||
)
|
||||
|
||||
@@ -664,6 +763,19 @@ async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Respons
|
||||
return await _create_webapp_payment(ctx, "platega_crypto")
|
||||
|
||||
|
||||
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
|
||||
service: PlategaService = ctx.request.app.get("platega_service")
|
||||
if not service or not service.configured:
|
||||
return None
|
||||
variant = "crypto" if ctx.method == "platega_crypto" else "sbp"
|
||||
return await service.try_reuse_pending_transaction(
|
||||
payment,
|
||||
user_id=ctx.user_id,
|
||||
sale_mode=ctx.sale_mode,
|
||||
variant=variant,
|
||||
)
|
||||
|
||||
|
||||
def _platega_presentation_manifest(subsection: str, default_icon: str, prefix: str) -> tuple:
|
||||
return tuple(
|
||||
ProviderManifestField(
|
||||
@@ -818,6 +930,7 @@ SBP_SPEC = PaymentProviderSpec(
|
||||
webhook_path=lambda source: "/webhook/platega",
|
||||
webhook_route=platega_webhook_route,
|
||||
create_webapp_payment=create_sbp_webapp_payment,
|
||||
reuse_webapp_payment=reuse_webapp_payment,
|
||||
config_class=PlategaConfig,
|
||||
presentation_class=PlategaSbpPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST
|
||||
@@ -850,6 +963,7 @@ CRYPTO_SPEC = PaymentProviderSpec(
|
||||
service_key="platega_service",
|
||||
callback_prefix="pay_platega_crypto",
|
||||
create_webapp_payment=create_crypto_webapp_payment,
|
||||
reuse_webapp_payment=reuse_webapp_payment,
|
||||
config_class=PlategaConfig,
|
||||
presentation_class=PlategaCryptoPresentation,
|
||||
manifest_fields=_platega_presentation_manifest("Platega", "Bitcoin", "PLATEGA_CRYPTO"),
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional
|
||||
|
||||
from . import cryptopay, freekassa, heleket, platega, severpay, stars, wata, yookassa
|
||||
from . import cryptopay, freekassa, heleket, paykilla, platega, severpay, stars, wata, yookassa
|
||||
from .base import (
|
||||
PaymentProviderPresentation,
|
||||
PaymentProviderSpec,
|
||||
@@ -21,6 +21,7 @@ PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
|
||||
stars.SPEC,
|
||||
cryptopay.SPEC,
|
||||
heleket.SPEC,
|
||||
paykilla.SPEC,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -51,11 +51,13 @@ from .shared import (
|
||||
notify_user_payment_failed,
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
post_json_request,
|
||||
quote_hwid_callback_parts,
|
||||
render_link_or_fail,
|
||||
render_payment_link,
|
||||
)
|
||||
|
||||
_LOG = "severpay"
|
||||
@@ -136,7 +138,7 @@ class SeverPayService(HttpClientMixin):
|
||||
self.referral_service = referral_service
|
||||
self._default_return_url = default_return_url
|
||||
|
||||
self._init_http_client(total_timeout=15)
|
||||
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
|
||||
|
||||
if not self.configured:
|
||||
logging.warning(
|
||||
@@ -248,6 +250,48 @@ class SeverPayService(HttpClientMixin):
|
||||
return True, response_data.get("data") or response_data
|
||||
return False, response_data
|
||||
|
||||
async def get_payment(self, provider_payment_id: str) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
provider_payment_id = str(provider_payment_id or "").strip()
|
||||
if not provider_payment_id:
|
||||
return False, {"message": "missing_payment_id"}
|
||||
|
||||
identifier: Dict[str, Any]
|
||||
if provider_payment_id.isdigit():
|
||||
identifier = {"id": int(provider_payment_id)}
|
||||
else:
|
||||
identifier = {"uid": provider_payment_id}
|
||||
|
||||
session = await self._get_session()
|
||||
success, response_data = await post_json_request(
|
||||
session,
|
||||
f"{self.base_url}/payin/get",
|
||||
body=self._build_signed_body(identifier),
|
||||
log_prefix="SeverPay get_payment",
|
||||
is_success=lambda status, data: status == 200 and bool((data or {}).get("status")),
|
||||
)
|
||||
if success:
|
||||
return True, response_data.get("data") or response_data
|
||||
return False, response_data
|
||||
|
||||
async def try_reuse_pending_payment(self, payment: Any) -> Optional[str]:
|
||||
provider_payment_id = str(getattr(payment, "provider_payment_id", None) or "").strip()
|
||||
payment_url = str(getattr(payment, "provider_payment_url", None) or "").strip()
|
||||
if not provider_payment_id or not payment_url:
|
||||
return None
|
||||
|
||||
success, data = await self.get_payment(provider_payment_id)
|
||||
if not success or str(data.get("status") or "").lower() not in {"new", "process"}:
|
||||
return None
|
||||
returned_ids = {str(data.get("id") or ""), str(data.get("uid") or "")}
|
||||
if provider_payment_id not in returned_ids:
|
||||
return None
|
||||
if str(data.get("order_id") or "") != str(payment.payment_id):
|
||||
return None
|
||||
return payment_url
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
|
||||
@@ -465,6 +509,38 @@ async def pay_severpay_callback_handler(
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
|
||||
reuse_amounts = payment_record_amounts(
|
||||
months=parts.months,
|
||||
sale_mode=parts.sale_mode,
|
||||
hwid_device_count=hwid_quote.get("device_count") if hwid_quote else None,
|
||||
)
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
provider="severpay",
|
||||
pending_status="pending_severpay",
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
sale_mode=parts.sale_mode,
|
||||
months=reuse_amounts.months,
|
||||
purchased_gb=reuse_amounts.purchased_gb,
|
||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||
tariff_key=reuse_amounts.tariff_key,
|
||||
)
|
||||
if reusable_payment is not None:
|
||||
reusable_url = await severpay_service.try_reuse_pending_payment(reusable_payment)
|
||||
if reusable_url:
|
||||
await render_payment_link(
|
||||
callback,
|
||||
translator=translator,
|
||||
current_lang=current_lang,
|
||||
i18n=i18n,
|
||||
parts=parts,
|
||||
payment_url=reusable_url,
|
||||
log_prefix=_LOG,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
payment_record = await payment_dal.create_payment_record(session, record_payload)
|
||||
await session.commit()
|
||||
@@ -552,6 +628,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
|
||||
service: SeverPayService = ctx.request.app.get("severpay_service")
|
||||
if not service or not service.configured:
|
||||
return None
|
||||
return await service.try_reuse_pending_payment(payment)
|
||||
|
||||
|
||||
_PRESENTATION_MANIFEST = tuple(
|
||||
ProviderManifestField(
|
||||
key=key,
|
||||
@@ -677,6 +760,7 @@ SPEC = PaymentProviderSpec(
|
||||
webhook_path=lambda source: "/webhook/severpay",
|
||||
webhook_route=severpay_webhook_route,
|
||||
create_webapp_payment=create_webapp_payment,
|
||||
reuse_webapp_payment=reuse_webapp_payment,
|
||||
config_class=SeverPayConfig,
|
||||
presentation_class=SeverPayPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
|
||||
@@ -42,6 +42,7 @@ from .common import (
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
reusable_webapp_payment_response,
|
||||
sale_mode_base,
|
||||
sale_mode_is_hwid_devices,
|
||||
sale_mode_is_traffic,
|
||||
@@ -117,6 +118,7 @@ __all__ = [
|
||||
"payment_link_message_text",
|
||||
"payment_link_response",
|
||||
"payment_record_amounts",
|
||||
"reusable_webapp_payment_response",
|
||||
"payment_units_for_activation",
|
||||
"payment_unavailable",
|
||||
"post_json_request",
|
||||
|
||||
@@ -8,8 +8,10 @@ from aiogram import types
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
HWID_RENEWAL_TOKEN,
|
||||
get_payment_url_keyboard,
|
||||
payment_methods_back_callback,
|
||||
sale_mode_has_token,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from db.dal import payment_dal
|
||||
@@ -123,6 +125,27 @@ async def quote_hwid_callback_parts(
|
||||
subscription_service,
|
||||
currency: str = "rub",
|
||||
) -> tuple[Optional[PaymentCallbackParts], Optional[dict]]:
|
||||
base = sale_mode_base(parts.sale_mode)
|
||||
if base == "subscription" and sale_mode_has_token(parts.sale_mode, HWID_RENEWAL_TOKEN):
|
||||
try:
|
||||
months = int(parts.months)
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
quote = await subscription_service.quote_hwid_device_renewal_for_subscription(
|
||||
session,
|
||||
user_id=user_id,
|
||||
target_tariff_key=sale_mode_tariff_key(parts.sale_mode),
|
||||
months=months,
|
||||
currency=currency,
|
||||
)
|
||||
if not quote:
|
||||
return parts, None
|
||||
quoted_parts = PaymentCallbackParts(
|
||||
months=months,
|
||||
price=float(parts.price or 0) + float(quote.get("price") or 0),
|
||||
sale_mode=parts.sale_mode,
|
||||
)
|
||||
return quoted_parts, quote
|
||||
if not sale_mode_is_hwid_devices(parts.sale_mode):
|
||||
return parts, None
|
||||
device_count = parse_positive_int_units(parts.months)
|
||||
@@ -262,6 +285,7 @@ async def safe_store_provider_payment_id(
|
||||
payment: Payment,
|
||||
*,
|
||||
provider_payment_id: str,
|
||||
provider_payment_url: Optional[str] = None,
|
||||
new_status: Optional[str] = None,
|
||||
log_prefix: str,
|
||||
) -> bool:
|
||||
@@ -277,6 +301,7 @@ async def safe_store_provider_payment_id(
|
||||
payment.payment_id,
|
||||
str(provider_payment_id),
|
||||
new_status or payment.status,
|
||||
provider_payment_url=provider_payment_url,
|
||||
)
|
||||
await session.commit()
|
||||
return True
|
||||
@@ -331,11 +356,12 @@ async def render_link_or_fail(
|
||||
payment as ``failed_creation``. Every link-style provider used to inline
|
||||
this same sequence.
|
||||
"""
|
||||
if api_success and provider_payment_id:
|
||||
if api_success and provider_payment_id and payment_url:
|
||||
await safe_store_provider_payment_id(
|
||||
session,
|
||||
payment,
|
||||
provider_payment_id=provider_payment_id,
|
||||
provider_payment_url=payment_url,
|
||||
new_status=new_status,
|
||||
log_prefix=log_prefix,
|
||||
)
|
||||
|
||||
@@ -100,6 +100,11 @@ def build_payment_record_payload(
|
||||
base = sale_mode_base(sale_mode)
|
||||
is_traffic = sale_mode_is_traffic(sale_mode)
|
||||
is_hwid = sale_mode_is_hwid_devices(sale_mode)
|
||||
hwid_devices = int(float(months)) if is_hwid else None
|
||||
if hwid_quote:
|
||||
quote_devices = parse_positive_int_units(hwid_quote.get("device_count"))
|
||||
if quote_devices is not None:
|
||||
hwid_devices = quote_devices
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"amount": amount,
|
||||
@@ -111,9 +116,9 @@ def build_payment_record_payload(
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode_tariff_key(sale_mode),
|
||||
"purchased_gb": float(months) if is_traffic else None,
|
||||
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
|
||||
"purchased_hwid_devices": hwid_devices,
|
||||
}
|
||||
if hwid_quote and is_hwid:
|
||||
if hwid_quote and hwid_devices is not None:
|
||||
payload.update(
|
||||
{
|
||||
"hwid_valid_from": hwid_quote.get("valid_from"),
|
||||
@@ -164,14 +169,20 @@ def payment_record_amounts(
|
||||
months: Any,
|
||||
sale_mode: str,
|
||||
traffic_gb: Optional[float] = None,
|
||||
hwid_device_count: Optional[int] = None,
|
||||
) -> PaymentRecordAmounts:
|
||||
traffic_sale = sale_mode_is_traffic(sale_mode)
|
||||
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
|
||||
units = traffic_gb if traffic_sale and traffic_gb is not None else months
|
||||
purchased_hwid_devices = int(float(months)) if hwid_devices_sale else None
|
||||
if not hwid_devices_sale and hwid_device_count is not None:
|
||||
parsed_hwid_devices = parse_positive_int_units(hwid_device_count)
|
||||
if parsed_hwid_devices is not None:
|
||||
purchased_hwid_devices = parsed_hwid_devices
|
||||
return PaymentRecordAmounts(
|
||||
months=int(float(units)) if traffic_sale else int(float(months)),
|
||||
purchased_gb=float(units) if traffic_sale else None,
|
||||
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
|
||||
purchased_hwid_devices=purchased_hwid_devices,
|
||||
tariff_key=sale_mode_tariff_key(sale_mode),
|
||||
traffic_sale=traffic_sale,
|
||||
hwid_devices_sale=hwid_devices_sale,
|
||||
@@ -281,6 +292,7 @@ async def create_webapp_payment_record(
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
return await create_base_payment_record(
|
||||
ctx.session,
|
||||
@@ -303,6 +315,45 @@ async def create_webapp_payment_record(
|
||||
)
|
||||
|
||||
|
||||
async def reusable_webapp_payment_response(
|
||||
ctx: WebAppPaymentContext,
|
||||
provider_spec: Any,
|
||||
*,
|
||||
since_minutes: Optional[int] = None,
|
||||
) -> Optional[web.Response]:
|
||||
resolver = getattr(provider_spec, "reuse_webapp_payment", None)
|
||||
if resolver is None:
|
||||
return None
|
||||
|
||||
amounts = payment_record_amounts(
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
ctx.session,
|
||||
user_id=ctx.user_id,
|
||||
provider=provider_spec.provider_key,
|
||||
pending_status=provider_spec.pending_status,
|
||||
amount=ctx.price,
|
||||
currency=ctx.currency,
|
||||
sale_mode=ctx.sale_mode,
|
||||
months=amounts.months,
|
||||
purchased_gb=amounts.purchased_gb,
|
||||
purchased_hwid_devices=amounts.purchased_hwid_devices,
|
||||
tariff_key=amounts.tariff_key,
|
||||
since_minutes=since_minutes,
|
||||
)
|
||||
if payment is None:
|
||||
return None
|
||||
|
||||
payment_url = await resolver(ctx, payment)
|
||||
if not payment_url:
|
||||
return None
|
||||
return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id)
|
||||
|
||||
|
||||
async def mark_payment_failed_creation(session: AsyncSession, payment_id: int) -> None:
|
||||
await payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation")
|
||||
await session.commit()
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, TraceConfig
|
||||
|
||||
SuccessCheck = Callable[[int, Any], bool]
|
||||
TimeoutSource = Union[float, Callable[[], float]]
|
||||
_TRANSPORT_ATTEMPTS = 2
|
||||
_DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||
|
||||
|
||||
def http_ok(status: int, _body: Any) -> bool:
|
||||
@@ -14,6 +18,29 @@ def http_ok(status: int, _body: Any) -> bool:
|
||||
return status == 200
|
||||
|
||||
|
||||
def _trace_request_ctx(trace_config_ctx: Any) -> Optional[dict]:
|
||||
ctx = getattr(trace_config_ctx, "trace_request_ctx", None)
|
||||
return ctx if isinstance(ctx, dict) else None
|
||||
|
||||
|
||||
async def _mark_request_headers_sent(session, trace_config_ctx, params) -> None:
|
||||
ctx = _trace_request_ctx(trace_config_ctx)
|
||||
if ctx is not None:
|
||||
ctx["headers_sent"] = True
|
||||
|
||||
|
||||
def _payment_trace_config() -> TraceConfig:
|
||||
trace_config = TraceConfig()
|
||||
trace_config.on_request_headers_sent.append(_mark_request_headers_sent)
|
||||
return trace_config
|
||||
|
||||
|
||||
def _should_retry_transport_error(exc: Exception, trace_ctx: Mapping[str, Any]) -> bool:
|
||||
if trace_ctx.get("headers_sent"):
|
||||
return False
|
||||
return isinstance(exc, (asyncio.TimeoutError, ClientError, OSError))
|
||||
|
||||
|
||||
async def post_json_request(
|
||||
session: ClientSession,
|
||||
url: str,
|
||||
@@ -29,34 +56,47 @@ async def post_json_request(
|
||||
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
|
||||
can decide what to do (typically: mark the payment as ``failed_creation``).
|
||||
"""
|
||||
try:
|
||||
async with session.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=dict(headers) if headers else None,
|
||||
) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": "invalid_json",
|
||||
"raw": response_text,
|
||||
}
|
||||
if not is_success(response.status, response_data):
|
||||
logging.error(
|
||||
"%s: API returned error (status=%s, body=%s)",
|
||||
for attempt in range(1, _TRANSPORT_ATTEMPTS + 1):
|
||||
trace_ctx: dict[str, Any] = {"headers_sent": False}
|
||||
try:
|
||||
async with session.post(
|
||||
url,
|
||||
json=body,
|
||||
headers=dict(headers) if headers else None,
|
||||
trace_request_ctx=trace_ctx,
|
||||
) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
|
||||
return False, {
|
||||
"status": response.status,
|
||||
"message": "invalid_json",
|
||||
"raw": response_text,
|
||||
}
|
||||
if not is_success(response.status, response_data):
|
||||
logging.error(
|
||||
"%s: API returned error (status=%s, body=%s)",
|
||||
log_prefix,
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
if attempt < _TRANSPORT_ATTEMPTS and _should_retry_transport_error(exc, trace_ctx):
|
||||
logging.warning(
|
||||
"%s: transport failed before request headers were sent; retrying (%s/%s): %s", # noqa: E501
|
||||
log_prefix,
|
||||
response.status,
|
||||
response_data,
|
||||
attempt + 1,
|
||||
_TRANSPORT_ATTEMPTS,
|
||||
exc,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.exception("%s: request failed.", log_prefix)
|
||||
return False, {"message": str(exc)}
|
||||
continue
|
||||
logging.exception("%s: request failed.", log_prefix)
|
||||
return False, {"message": str(exc)}
|
||||
return False, {"message": "request_failed"}
|
||||
|
||||
|
||||
def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]:
|
||||
@@ -76,20 +116,69 @@ class HttpClientMixin:
|
||||
Each subclass calls ``self._init_http_client(total_timeout=...)`` from
|
||||
``__init__`` and inherits ``_get_session`` / ``close``. The session is
|
||||
created on first use and recreated transparently if it was closed.
|
||||
|
||||
``total_timeout`` may be a callable so the timeout follows runtime
|
||||
settings changes (admin overrides apply in-process without a restart).
|
||||
When the value changes, the next request gets a fresh session; the old
|
||||
session stays open until its own in-flight requests cannot outlive it.
|
||||
|
||||
Provider API calls are traced so callers can retry transport failures only
|
||||
when aiohttp has not sent request headers yet.
|
||||
"""
|
||||
|
||||
_timeout: ClientTimeout
|
||||
_timeout_source: TimeoutSource
|
||||
_session: Optional[ClientSession]
|
||||
_stale_sessions: List[ClientSession]
|
||||
_session_cleanup_tasks: Set["asyncio.Task[None]"]
|
||||
|
||||
def _init_http_client(self, *, total_timeout: float = 20.0) -> None:
|
||||
self._timeout = ClientTimeout(total=total_timeout)
|
||||
def _init_http_client(self, *, total_timeout: TimeoutSource = _DEFAULT_TIMEOUT_SECONDS) -> None:
|
||||
self._timeout_source = total_timeout
|
||||
self._session = None
|
||||
self._stale_sessions = []
|
||||
self._session_cleanup_tasks = set()
|
||||
|
||||
def _current_timeout_seconds(self) -> float:
|
||||
source = self._timeout_source
|
||||
try:
|
||||
seconds = float(source() if callable(source) else source)
|
||||
except Exception:
|
||||
return _DEFAULT_TIMEOUT_SECONDS
|
||||
return seconds if seconds > 0 else _DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(timeout=self._timeout)
|
||||
return self._session
|
||||
timeout_seconds = self._current_timeout_seconds()
|
||||
session = self._session
|
||||
if session is not None and not session.closed and session.timeout.total != timeout_seconds:
|
||||
self._session = None
|
||||
self._stale_sessions.append(session)
|
||||
task = asyncio.create_task(self._close_stale_session(session))
|
||||
self._session_cleanup_tasks.add(task)
|
||||
task.add_done_callback(self._session_cleanup_tasks.discard)
|
||||
session = None
|
||||
if session is None or session.closed:
|
||||
session = ClientSession(
|
||||
timeout=ClientTimeout(total=timeout_seconds),
|
||||
trace_configs=[_payment_trace_config()],
|
||||
)
|
||||
self._session = session
|
||||
return session
|
||||
|
||||
async def _close_stale_session(self, session: ClientSession) -> None:
|
||||
# Any request started on this session is bound by its total timeout,
|
||||
# so after that long it is safe to close without cutting one off.
|
||||
await asyncio.sleep((session.timeout.total or _DEFAULT_TIMEOUT_SECONDS) + 1.0)
|
||||
if session in self._stale_sessions:
|
||||
self._stale_sessions.remove(session)
|
||||
if not session.closed:
|
||||
await session.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
for task in list(self._session_cleanup_tasks):
|
||||
task.cancel()
|
||||
self._session_cleanup_tasks.clear()
|
||||
sessions = [self._session, *self._stale_sessions]
|
||||
self._session = None
|
||||
self._stale_sessions = []
|
||||
for session in sessions:
|
||||
if session and not session.closed:
|
||||
await session.close()
|
||||
|
||||
@@ -156,6 +156,28 @@ def append_hwid_renewal_note(
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
def append_hwid_renewed_note(
|
||||
text: str,
|
||||
translator: Translator,
|
||||
*,
|
||||
count: Any,
|
||||
valid_until: Optional[datetime],
|
||||
) -> str:
|
||||
try:
|
||||
count_int = int(count or 0)
|
||||
except (TypeError, ValueError):
|
||||
count_int = 0
|
||||
if count_int <= 0:
|
||||
return text
|
||||
date_text = valid_until.strftime("%Y-%m-%d") if valid_until else ""
|
||||
note = translator(
|
||||
"payment_successful_hwid_devices_renewed_note",
|
||||
count=format_human_units(count_int),
|
||||
date=date_text,
|
||||
)
|
||||
return f"{text}\n\n{note}"
|
||||
|
||||
|
||||
async def send_success_message_to_user(
|
||||
*,
|
||||
bot: Bot,
|
||||
@@ -320,8 +342,37 @@ async def finalize_successful_payment(
|
||||
req.log_prefix,
|
||||
req.payment.payment_id,
|
||||
)
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
req.session,
|
||||
req.payment.payment_id,
|
||||
"activation_failed",
|
||||
)
|
||||
await req.session.commit()
|
||||
except Exception:
|
||||
await req.session.rollback()
|
||||
logging.exception(
|
||||
"%s: failed to mark payment %s activation_failed.",
|
||||
req.log_prefix,
|
||||
req.payment.payment_id,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches
|
||||
|
||||
await invalidate_webapp_user_caches(
|
||||
req.settings,
|
||||
req.user_id,
|
||||
include_devices=True,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"%s: failed to invalidate webapp caches for user %s.",
|
||||
req.log_prefix,
|
||||
req.user_id,
|
||||
)
|
||||
|
||||
db_user, language = await resolve_user_language(
|
||||
req.session,
|
||||
user_id=req.user_id,
|
||||
@@ -363,12 +414,20 @@ async def finalize_successful_payment(
|
||||
)
|
||||
)
|
||||
if is_subscription and activation:
|
||||
success_text = append_hwid_renewal_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewal_recommended_count"),
|
||||
valid_until=activation.get("hwid_devices_valid_until"),
|
||||
)
|
||||
if activation.get("hwid_devices_renewed_count"):
|
||||
success_text = append_hwid_renewed_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewed_count"),
|
||||
valid_until=final_end_date or activation.get("hwid_devices_renewed_until"),
|
||||
)
|
||||
else:
|
||||
success_text = append_hwid_renewal_note(
|
||||
success_text,
|
||||
translator,
|
||||
count=activation.get("hwid_devices_renewal_recommended_count"),
|
||||
valid_until=activation.get("hwid_devices_valid_until"),
|
||||
)
|
||||
if req.text_prefix:
|
||||
success_text = f"{req.text_prefix}\n{success_text}"
|
||||
|
||||
|
||||
@@ -39,13 +39,16 @@ async def finalize_webapp_link_payment(
|
||||
log_prefix="Wata",
|
||||
)
|
||||
"""
|
||||
if api_success and provider_payment_id:
|
||||
# Reuse logic needs both a provider id and a redirect URL; persisting only
|
||||
# the id creates orphan records that match find_recent but fail verification.
|
||||
if api_success and provider_payment_id and payment_url:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment.payment_id,
|
||||
str(provider_payment_id),
|
||||
new_status or payment.status,
|
||||
provider_payment_url=payment_url,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
|
||||
@@ -51,7 +51,7 @@ async def notify_user_payment_failed(
|
||||
message_key: str = "payment_failed",
|
||||
) -> None:
|
||||
"""Send the localized ``payment_failed`` text to the user; never raises."""
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
db_user = await user_dal.get_user_by_id(session, payment.user_id)
|
||||
language = (
|
||||
db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
@@ -344,6 +344,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
|
||||
@@ -54,7 +54,6 @@ from .shared import (
|
||||
notify_user_payment_failed,
|
||||
parse_payment_callback,
|
||||
payment_failed,
|
||||
payment_link_response,
|
||||
payment_record_amounts,
|
||||
payment_unavailable,
|
||||
payment_units_for_activation,
|
||||
@@ -63,7 +62,6 @@ from .shared import (
|
||||
render_link_or_fail,
|
||||
render_payment_link,
|
||||
safe_callback_answer,
|
||||
sale_mode_base,
|
||||
)
|
||||
|
||||
router = Router(name="user_subscription_payments_wata_router")
|
||||
@@ -206,7 +204,7 @@ class WataService(HttpClientMixin):
|
||||
self._default_return_url = default_return_url
|
||||
self._cached_public_key_pem = None # populated by webhook on first verify
|
||||
|
||||
self._init_http_client(total_timeout=10)
|
||||
self._init_http_client(total_timeout=lambda: self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
|
||||
if not self.configured:
|
||||
logging.warning("WataService initialized but not fully configured. Payments disabled.")
|
||||
|
||||
@@ -361,6 +359,19 @@ class WataService(HttpClientMixin):
|
||||
if not success or not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
returned_ids = {
|
||||
str(data.get("id") or "").strip(),
|
||||
str(data.get("paymentLinkId") or "").strip(),
|
||||
str(data.get("payment_link_id") or "").strip(),
|
||||
}
|
||||
returned_ids.discard("")
|
||||
if returned_ids and provider_payment_id not in returned_ids:
|
||||
return None
|
||||
|
||||
order_id = first_value(data, "orderId", "order_id")
|
||||
if order_id is not None and str(order_id) != str(payment.payment_id):
|
||||
return None
|
||||
|
||||
status = _normalized_wata_status(data) or str(data.get("status") or "").strip().lower()
|
||||
if status and status not in _WATA_LINK_OPENED_STATUSES:
|
||||
return None
|
||||
@@ -889,17 +900,15 @@ async def pay_wata_callback_handler(
|
||||
payment_description = describe_payment(translator, parts)
|
||||
|
||||
reuse_amounts = payment_record_amounts(months=parts.months, sale_mode=parts.sale_mode)
|
||||
months_for_lookup = (
|
||||
reuse_amounts.months if sale_mode_base(parts.sale_mode) == "subscription" else None
|
||||
)
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
session,
|
||||
user_id=callback.from_user.id,
|
||||
provider="wata",
|
||||
pending_status="pending_wata",
|
||||
amount=parts.price,
|
||||
currency=currency_code,
|
||||
sale_mode=parts.sale_mode,
|
||||
months=months_for_lookup,
|
||||
months=reuse_amounts.months,
|
||||
purchased_gb=reuse_amounts.purchased_gb,
|
||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||
tariff_key=reuse_amounts.tariff_key,
|
||||
@@ -974,44 +983,6 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
|
||||
currency = ctx.currency or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
|
||||
|
||||
reuse_amounts = payment_record_amounts(
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
)
|
||||
months_for_lookup = (
|
||||
reuse_amounts.months if sale_mode_base(ctx.sale_mode) == "subscription" else None
|
||||
)
|
||||
try:
|
||||
reusable_payment = await payment_dal.find_recent_pending_provider_payment(
|
||||
ctx.session,
|
||||
user_id=ctx.user_id,
|
||||
provider="wata",
|
||||
pending_status="pending_wata",
|
||||
amount=ctx.price,
|
||||
sale_mode=ctx.sale_mode,
|
||||
months=months_for_lookup,
|
||||
purchased_gb=reuse_amounts.purchased_gb,
|
||||
purchased_hwid_devices=reuse_amounts.purchased_hwid_devices,
|
||||
tariff_key=reuse_amounts.tariff_key,
|
||||
since_minutes=service.payment_link_ttl_minutes,
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Wata WebApp: lookup of reusable payment failed")
|
||||
reusable_payment = None
|
||||
|
||||
if reusable_payment is not None:
|
||||
try:
|
||||
reusable_url = await service.try_reuse_pending_link(reusable_payment)
|
||||
except Exception:
|
||||
logging.exception("Wata WebApp: failed to verify reusable link")
|
||||
reusable_url = None
|
||||
if reusable_url:
|
||||
return payment_link_response(
|
||||
payment_url=reusable_url,
|
||||
payment_id=reusable_payment.payment_id,
|
||||
)
|
||||
|
||||
try:
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
@@ -1043,6 +1014,13 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
)
|
||||
|
||||
|
||||
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
|
||||
service: WataService = ctx.request.app.get("wata_service")
|
||||
if not service or not service.configured:
|
||||
return None
|
||||
return await service.try_reuse_pending_link(payment)
|
||||
|
||||
|
||||
async def wata_webhook_route(request: web.Request) -> web.Response:
|
||||
service: WataService = request.app["wata_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -1203,6 +1181,7 @@ SPEC = PaymentProviderSpec(
|
||||
webhook_path=lambda source: "/webhook/wata",
|
||||
webhook_route=wata_webhook_route,
|
||||
create_webapp_payment=create_webapp_payment,
|
||||
reuse_webapp_payment=reuse_webapp_payment,
|
||||
config_class=WataConfig,
|
||||
presentation_class=WataPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
|
||||
@@ -382,6 +382,10 @@ class YooKassaService:
|
||||
"title": pm_title,
|
||||
"card_last4": last4_val,
|
||||
}
|
||||
confirmation = getattr(payment_info_yk, "confirmation", None)
|
||||
confirmation_url = (
|
||||
getattr(confirmation, "confirmation_url", None) if confirmation else None
|
||||
)
|
||||
return {
|
||||
"id": payment_info_yk.id,
|
||||
"status": payment_info_yk.status,
|
||||
@@ -399,6 +403,7 @@ class YooKassaService:
|
||||
and hasattr(payment_info_yk.captured_at, "isoformat")
|
||||
else None,
|
||||
"payment_method": pm_payload,
|
||||
"confirmation_url": confirmation_url,
|
||||
"test_mode": getattr(payment_info_yk, "test", None),
|
||||
}
|
||||
else:
|
||||
@@ -448,6 +453,36 @@ def _metadata_value_present(value: Optional[Any]) -> bool:
|
||||
return value is not None and str(value).strip() != ""
|
||||
|
||||
|
||||
def _metadata_int(value: Optional[Any]) -> Optional[int]:
|
||||
if not _metadata_value_present(value):
|
||||
return None
|
||||
try:
|
||||
return int(float(str(value).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_float(value: Optional[Any]) -> Optional[float]:
|
||||
if not _metadata_value_present(value):
|
||||
return None
|
||||
try:
|
||||
return float(str(value).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]:
|
||||
if not _metadata_value_present(value):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _resolve_yookassa_activation_amounts(
|
||||
*,
|
||||
sale_mode_base: str,
|
||||
@@ -559,6 +594,11 @@ async def process_successful_payment(
|
||||
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
|
||||
payment_value = float(amount_data.get("value", 0.0))
|
||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||
hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from"))
|
||||
hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until"))
|
||||
hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months"))
|
||||
hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio"))
|
||||
hwid_full_price = _metadata_float(metadata.get("hwid_full_price"))
|
||||
|
||||
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
|
||||
logging.error(
|
||||
@@ -574,6 +614,19 @@ async def process_successful_payment(
|
||||
yk_payment_id_from_hook,
|
||||
)
|
||||
return
|
||||
if sale_mode_base == "subscription" and hwid_devices_count > 0:
|
||||
if (
|
||||
not hwid_valid_from
|
||||
or not hwid_valid_until
|
||||
or hwid_valid_from >= hwid_valid_until
|
||||
or hwid_full_price is None
|
||||
):
|
||||
logging.error(
|
||||
"YooKassa subscription+HWID payment %s has invalid HWID metadata: %s",
|
||||
yk_payment_id_from_hook,
|
||||
metadata,
|
||||
)
|
||||
return
|
||||
|
||||
payment_record = None
|
||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
||||
@@ -600,6 +653,16 @@ async def process_successful_payment(
|
||||
or f"Auto-renewal for {months_for_record or subscription_months} months",
|
||||
provider="yookassa",
|
||||
provider_payment_id=yk_payment_id_from_hook,
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=_sale_mode_tariff_key(sale_mode),
|
||||
purchased_hwid_devices=(
|
||||
hwid_devices_count if hwid_devices_count > 0 else None
|
||||
),
|
||||
hwid_valid_from=hwid_valid_from,
|
||||
hwid_valid_until=hwid_valid_until,
|
||||
hwid_pricing_period_months=hwid_pricing_period_months,
|
||||
hwid_proration_ratio=hwid_proration_ratio,
|
||||
hwid_full_price=hwid_full_price,
|
||||
)
|
||||
payment_db_id = payment_record.payment_id
|
||||
except Exception as e_ensure:
|
||||
@@ -1315,6 +1378,36 @@ def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]:
|
||||
parts = payload.split(":")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price = float(parts[1])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
page = 0
|
||||
sale_mode = "subscription"
|
||||
if len(parts) > 2:
|
||||
try:
|
||||
page = int(parts[2])
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except ValueError:
|
||||
sale_mode = parts[2]
|
||||
return months, price, page, sale_mode
|
||||
|
||||
|
||||
def _metadata_iso(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _format_saved_payment_method_title(
|
||||
get_text, network: Optional[str], last4: Optional[str], is_default: bool
|
||||
) -> str:
|
||||
@@ -1363,6 +1456,9 @@ async def _initiate_yk_payment(
|
||||
return False
|
||||
|
||||
sale_base = _sale_mode_base(sale_mode)
|
||||
hwid_device_count = None
|
||||
if hwid_quote:
|
||||
hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count"))
|
||||
payment_description = (
|
||||
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
@@ -1379,12 +1475,14 @@ async def _initiate_yk_payment(
|
||||
"status": "pending_yookassa",
|
||||
"description": payment_description,
|
||||
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
||||
"sale_mode": sale_base,
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None,
|
||||
"purchased_gb": float(months)
|
||||
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
||||
else None,
|
||||
"purchased_hwid_devices": int(months) if sale_base in HWID_DEVICE_SALE_BASES else None,
|
||||
"purchased_hwid_devices": (
|
||||
int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count
|
||||
),
|
||||
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
||||
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
||||
@@ -1430,6 +1528,19 @@ async def _initiate_yk_payment(
|
||||
yookassa_metadata["traffic_gb"] = str(months)
|
||||
if sale_base in HWID_DEVICE_SALE_BASES:
|
||||
yookassa_metadata["hwid_devices"] = str(months)
|
||||
elif hwid_device_count:
|
||||
yookassa_metadata["hwid_devices"] = str(hwid_device_count)
|
||||
if hwid_quote and hwid_device_count:
|
||||
hwid_metadata = {
|
||||
"hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")),
|
||||
"hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")),
|
||||
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
|
||||
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
|
||||
"hwid_full_price": hwid_quote.get("full_price"),
|
||||
}
|
||||
yookassa_metadata.update(
|
||||
{key: str(value) for key, value in hwid_metadata.items() if value is not None}
|
||||
)
|
||||
if payment_method_id:
|
||||
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
||||
|
||||
@@ -1497,7 +1608,7 @@ async def _initiate_yk_payment(
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=payment_response_yk.get("status", "pending"),
|
||||
new_status="pending_yookassa",
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
@@ -1565,12 +1676,11 @@ async def _initiate_yk_payment(
|
||||
return True
|
||||
|
||||
if payment_response_yk and payment_method_id:
|
||||
status_to_store = payment_response_yk.get("status", "pending")
|
||||
try:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
session,
|
||||
payment_db_id=db_payment_record.payment_id,
|
||||
new_status=status_to_store,
|
||||
new_status="pending_yookassa",
|
||||
yk_payment_id=payment_response_yk.get("id"),
|
||||
)
|
||||
if selected_method_internal_id is not None:
|
||||
@@ -1709,22 +1819,6 @@ async def pay_yk_callback_handler(
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
hwid_quote = None
|
||||
if _sale_mode_base(sale_mode) in HWID_DEVICE_SALE_BASES:
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||
autopay_enabled = bool(
|
||||
@@ -1786,6 +1880,22 @@ async def pay_yk_callback_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
|
||||
await _initiate_yk_payment(
|
||||
callback,
|
||||
settings=settings,
|
||||
@@ -1863,6 +1973,22 @@ async def pay_yk_new_card_handler(
|
||||
return
|
||||
|
||||
months, price_rub, sale_mode = parsed
|
||||
hwid_quote = None
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=callback.from_user.id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
user_id = callback.from_user.id
|
||||
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
||||
autopay_enabled = bool(
|
||||
@@ -1889,6 +2015,7 @@ async def pay_yk_new_card_handler(
|
||||
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||
back_callback=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
|
||||
sale_mode=sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
@@ -1928,27 +2055,15 @@ async def pay_yk_saved_list_handler(
|
||||
pass
|
||||
return
|
||||
|
||||
parts = data_payload.split(":")
|
||||
if len(parts) < 2:
|
||||
parsed_saved_list = _parse_saved_list_payload(data_payload)
|
||||
if not parsed_saved_list:
|
||||
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
months = float(parts[0])
|
||||
price_rub = float(parts[1])
|
||||
page = int(parts[2]) if len(parts) > 2 else 0
|
||||
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months, price_rub, page, sale_mode = parsed_saved_list
|
||||
|
||||
autopay_enabled = bool(
|
||||
settings.yookassa_autopayments_active
|
||||
@@ -2138,6 +2253,24 @@ async def pay_yk_use_saved_handler(
|
||||
|
||||
method_identifier = parts[2]
|
||||
user_id = callback.from_user.id
|
||||
base_months = months
|
||||
base_price_rub = price_rub
|
||||
hwid_quote = None
|
||||
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
||||
subscription_service=yookassa_service.subscription_service,
|
||||
currency=default_currency_key_for_settings(settings),
|
||||
)
|
||||
if not quoted_parts:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
months = quoted_parts.months
|
||||
price_rub = quoted_parts.price
|
||||
|
||||
try:
|
||||
saved_methods = await user_billing_dal.list_user_payment_methods(
|
||||
@@ -2182,10 +2315,13 @@ async def pay_yk_use_saved_handler(
|
||||
price_rub=price_rub,
|
||||
currency_code_for_yk=currency_code_for_yk,
|
||||
save_payment_method=False,
|
||||
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
||||
back_callback=(
|
||||
f"pay_yk_saved_list:{_format_value(base_months)}:{base_price_rub}:0:{sale_mode}"
|
||||
),
|
||||
payment_method_id=selected_method.provider_payment_method_id,
|
||||
selected_method_internal_id=selected_method.method_id,
|
||||
sale_mode=sale_mode,
|
||||
hwid_quote=hwid_quote,
|
||||
)
|
||||
try:
|
||||
await callback.answer()
|
||||
@@ -2754,6 +2890,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
months=ctx.months,
|
||||
sale_mode=ctx.sale_mode,
|
||||
traffic_gb=ctx.traffic_gb,
|
||||
hwid_device_count=ctx.hwid_device_count,
|
||||
)
|
||||
payment = await create_webapp_payment_record(
|
||||
ctx,
|
||||
@@ -2775,8 +2912,8 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
}
|
||||
if amounts.traffic_sale:
|
||||
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
|
||||
if amounts.hwid_devices_sale:
|
||||
metadata["hwid_devices"] = str(int(float(ctx.months)))
|
||||
if amounts.purchased_hwid_devices:
|
||||
metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices))
|
||||
if amounts.tariff_key:
|
||||
metadata["tariff_key"] = amounts.tariff_key
|
||||
response = await service.create_payment(
|
||||
@@ -2795,7 +2932,7 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
await payment_dal.update_payment_status_by_db_id(
|
||||
ctx.session,
|
||||
payment.payment_id,
|
||||
response.get("status", "pending"),
|
||||
"pending_yookassa",
|
||||
yk_payment_id=response.get("id"),
|
||||
)
|
||||
await ctx.session.commit()
|
||||
@@ -2806,6 +2943,36 @@ async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
||||
return payment_failed()
|
||||
|
||||
|
||||
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
|
||||
service: YooKassaService = ctx.request.app.get("yookassa_service")
|
||||
if not service or not service.configured:
|
||||
return None
|
||||
|
||||
provider_payment_id = str(
|
||||
getattr(payment, "yookassa_payment_id", None)
|
||||
or getattr(payment, "provider_payment_id", None)
|
||||
or ""
|
||||
).strip()
|
||||
if not provider_payment_id:
|
||||
return None
|
||||
|
||||
info = await service.get_payment_info(provider_payment_id)
|
||||
if not info or str(info.get("status") or "").strip().lower() != "pending":
|
||||
return None
|
||||
if bool(info.get("paid")):
|
||||
return None
|
||||
|
||||
metadata = info.get("metadata") or {}
|
||||
expected_metadata = {
|
||||
"user_id": str(ctx.user_id),
|
||||
"payment_db_id": str(payment.payment_id),
|
||||
"sale_mode": str(ctx.sale_mode),
|
||||
}
|
||||
if any(str(metadata.get(key) or "") != value for key, value in expected_metadata.items()):
|
||||
return None
|
||||
return str(info.get("confirmation_url") or "").strip() or None
|
||||
|
||||
|
||||
_PRESENTATION_MANIFEST = tuple(
|
||||
ProviderManifestField(
|
||||
key=key,
|
||||
@@ -2940,6 +3107,7 @@ SPEC = PaymentProviderSpec(
|
||||
webhook_route=yookassa_webhook_route,
|
||||
webhook_requires_base_url=True,
|
||||
create_webapp_payment=create_webapp_payment,
|
||||
reuse_webapp_payment=reuse_webapp_payment,
|
||||
config_class=YooKassaConfig,
|
||||
presentation_class=YooKassaPresentation,
|
||||
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""Detect common deployment misconfigurations for the admin panel.
|
||||
|
||||
Each check returns :class:`ConfigAlert` items the admin UI renders as
|
||||
banners on the dashboard and inside the affected sections. Local checks
|
||||
(filesystem, settings flags) run on every request; network checks
|
||||
(Telegram webhook, Remnawave panel) are cached for a couple of minutes so
|
||||
the dashboard stays fast and external APIs are not hammered.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from bot.utils.request_security import ip_in_allowlist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
NETWORK_CHECKS_TTL_SECONDS = 120.0
|
||||
NETWORK_CHECK_TIMEOUT_SECONDS = 8.0
|
||||
_WEBHOOK_ERROR_RECENT_SECONDS = 3600
|
||||
_WEBHOOK_PENDING_THRESHOLD = 50
|
||||
|
||||
SEVERITY_ERROR = "error"
|
||||
SEVERITY_WARNING = "warning"
|
||||
|
||||
# Admin section ids the frontend routes alerts to.
|
||||
SECTION_SETTINGS = "settings"
|
||||
SECTION_PAYMENTS = "payments"
|
||||
SECTION_BACKUPS = "backups"
|
||||
SECTION_TARIFFS = "tariffs"
|
||||
SECTION_APPEARANCE = "appearance"
|
||||
SECTION_TRANSLATIONS = "translations"
|
||||
SECTION_USERS = "users"
|
||||
|
||||
_DATA_DIR_SECTIONS = (
|
||||
SECTION_BACKUPS,
|
||||
SECTION_TARIFFS,
|
||||
SECTION_APPEARANCE,
|
||||
SECTION_TRANSLATIONS,
|
||||
SECTION_SETTINGS,
|
||||
)
|
||||
|
||||
# Every message key an alert can carry. Tests assert each has
|
||||
# ``admin_health_<key>`` entries in both locale files.
|
||||
ALL_MESSAGE_KEYS = (
|
||||
"data_dir_missing",
|
||||
"data_dir_not_writable",
|
||||
"backups_dir_not_writable",
|
||||
"tariffs_config_invalid",
|
||||
"locale_overrides_invalid",
|
||||
"subscription_page_config_invalid",
|
||||
"provider_not_configured",
|
||||
"provider_webhook_needs_base_url",
|
||||
"no_payment_methods",
|
||||
"mini_app_url_missing",
|
||||
"mini_app_url_not_https",
|
||||
"redis_not_configured",
|
||||
"smtp_incomplete",
|
||||
"proxy_not_trusted",
|
||||
"bot_token_invalid",
|
||||
"telegram_api_error",
|
||||
"telegram_webhook_missing",
|
||||
"telegram_webhook_mismatch",
|
||||
"telegram_webhook_error",
|
||||
"telegram_webhook_pending",
|
||||
"panel_api_not_configured",
|
||||
"panel_api_unreachable",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigAlert:
|
||||
id: str
|
||||
severity: str
|
||||
sections: Tuple[str, ...]
|
||||
params: Dict[str, Any] = field(default_factory=dict)
|
||||
# Locale key suffix; defaults to ``id``. Per-provider alerts carry ids
|
||||
# like ``provider_not_configured:wata`` but share one message key.
|
||||
message_key: Optional[str] = None
|
||||
|
||||
def as_payload(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"severity": self.severity,
|
||||
"sections": list(self.sections),
|
||||
"message_key": self.message_key or self.id,
|
||||
"params": dict(self.params),
|
||||
}
|
||||
|
||||
|
||||
# ─── Filesystem checks ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def _dir_is_writable(path: Path) -> bool:
|
||||
probe = path / f".health-probe-{uuid.uuid4().hex}.tmp"
|
||||
try:
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
try:
|
||||
probe.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_data_path(value: str) -> Path:
|
||||
path = Path(value)
|
||||
return path if path.is_absolute() else APP_ROOT / path
|
||||
|
||||
|
||||
def data_dir_alerts(settings: Any, app_root: Path = APP_ROOT) -> List[ConfigAlert]:
|
||||
alerts: List[ConfigAlert] = []
|
||||
data_dir = app_root / "data"
|
||||
if not data_dir.is_dir():
|
||||
return [
|
||||
ConfigAlert(
|
||||
id="data_dir_missing",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=_DATA_DIR_SECTIONS,
|
||||
params={"path": str(data_dir)},
|
||||
)
|
||||
]
|
||||
if not _dir_is_writable(data_dir):
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="data_dir_not_writable",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=_DATA_DIR_SECTIONS,
|
||||
params={"path": str(data_dir)},
|
||||
)
|
||||
)
|
||||
|
||||
backup_dir = _resolve_data_path(str(getattr(settings, "BACKUP_DIR", "") or "data/backups"))
|
||||
if backup_dir.is_dir() and not _dir_is_writable(backup_dir):
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="backups_dir_not_writable",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_BACKUPS,),
|
||||
params={"path": str(backup_dir)},
|
||||
)
|
||||
)
|
||||
return alerts
|
||||
|
||||
|
||||
def config_file_alerts(settings: Any) -> List[ConfigAlert]:
|
||||
alerts: List[ConfigAlert] = []
|
||||
|
||||
tariffs_path = _resolve_data_path(
|
||||
str(getattr(settings, "TARIFFS_CONFIG_PATH", "") or "data/tariffs.json")
|
||||
)
|
||||
if tariffs_path.is_file():
|
||||
try:
|
||||
from config.tariffs_config import load_tariffs_config
|
||||
|
||||
load_tariffs_config(tariffs_path)
|
||||
except Exception as exc:
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="tariffs_config_invalid",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=(SECTION_TARIFFS,),
|
||||
params={"path": str(tariffs_path), "error": str(exc)[:300]},
|
||||
)
|
||||
)
|
||||
|
||||
locale_overrides_path = APP_ROOT / "data" / "locales-overrides.json"
|
||||
if locale_overrides_path.is_file():
|
||||
try:
|
||||
json.loads(locale_overrides_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="locale_overrides_invalid",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_TRANSLATIONS,),
|
||||
params={"path": str(locale_overrides_path), "error": str(exc)[:300]},
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from config.subscription_guides_config import (
|
||||
SubscriptionGuidesConfigError,
|
||||
subscription_guides_admin_config_json,
|
||||
)
|
||||
|
||||
try:
|
||||
subscription_guides_admin_config_json(settings)
|
||||
except SubscriptionGuidesConfigError as exc:
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="subscription_page_config_invalid",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
params={"error": str(exc)[:300]},
|
||||
)
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive import guard
|
||||
logger.exception("Subscription guides config check failed unexpectedly")
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
# ─── Settings checks ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def payment_provider_alerts(settings: Any, app: Any) -> List[ConfigAlert]:
|
||||
from bot.payment_providers import iter_provider_specs
|
||||
|
||||
alerts: List[ConfigAlert] = []
|
||||
any_enabled = False
|
||||
seen_services: set = set()
|
||||
for spec in iter_provider_specs():
|
||||
try:
|
||||
enabled = spec.is_effectively_enabled(settings)
|
||||
except Exception: # pragma: no cover - provider config errors
|
||||
logger.exception("Provider %s enabled check failed", spec.id)
|
||||
continue
|
||||
if not enabled:
|
||||
continue
|
||||
any_enabled = True
|
||||
if spec.service_key in seen_services:
|
||||
continue
|
||||
if spec.service_key:
|
||||
seen_services.add(spec.service_key)
|
||||
if not spec.is_service_configured(app):
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id=f"provider_not_configured:{spec.id}",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
params={"provider": spec.label},
|
||||
message_key="provider_not_configured",
|
||||
)
|
||||
)
|
||||
if spec.webhook_requires_base_url and not getattr(settings, "WEBHOOK_BASE_URL", None):
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id=f"provider_webhook_needs_base_url:{spec.id}",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
params={"provider": spec.label},
|
||||
message_key="provider_webhook_needs_base_url",
|
||||
)
|
||||
)
|
||||
if not any_enabled:
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="no_payment_methods",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS, SECTION_PAYMENTS),
|
||||
)
|
||||
)
|
||||
return alerts
|
||||
|
||||
|
||||
def settings_alerts(settings: Any) -> List[ConfigAlert]:
|
||||
alerts: List[ConfigAlert] = []
|
||||
|
||||
mini_app_url = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
|
||||
if not mini_app_url:
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="mini_app_url_missing",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
)
|
||||
)
|
||||
elif not mini_app_url.lower().startswith("https://"):
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="mini_app_url_not_https",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
params={"url": mini_app_url},
|
||||
)
|
||||
)
|
||||
|
||||
if not getattr(settings, "REDIS_URL", None):
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="redis_not_configured",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
)
|
||||
)
|
||||
|
||||
smtp_partial = any(
|
||||
getattr(settings, key, None)
|
||||
for key in ("SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_FROM_EMAIL")
|
||||
)
|
||||
if smtp_partial and not getattr(settings, "email_auth_configured", False):
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="smtp_incomplete",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
)
|
||||
)
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
def proxy_alerts(request: Any, settings: Any) -> List[ConfigAlert]:
|
||||
"""Warn when the admin request itself came through an untrusted proxy.
|
||||
|
||||
In that case provider webhooks with IP allowlists will see the proxy
|
||||
address instead of the real sender and may reject valid callbacks.
|
||||
"""
|
||||
headers = getattr(request, "headers", None) or {}
|
||||
forwarded = headers.get("X-Forwarded-For")
|
||||
remote = getattr(request, "remote", None)
|
||||
if not forwarded or not remote:
|
||||
return []
|
||||
if ip_in_allowlist(remote, getattr(settings, "trusted_proxies", None)):
|
||||
return []
|
||||
return [
|
||||
ConfigAlert(
|
||||
id="proxy_not_trusted",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
params={"remote": str(remote)},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
# ─── Network checks (cached) ───────────────────────────────────────
|
||||
|
||||
|
||||
async def telegram_alerts(bot: Any, settings: Any) -> List[ConfigAlert]:
|
||||
if bot is None:
|
||||
return []
|
||||
try:
|
||||
info = await asyncio.wait_for(bot.get_webhook_info(), timeout=NETWORK_CHECK_TIMEOUT_SECONDS)
|
||||
except Exception as exc:
|
||||
if exc.__class__.__name__ in {"TelegramUnauthorizedError", "TelegramNotFound"}:
|
||||
return [
|
||||
ConfigAlert(
|
||||
id="bot_token_invalid",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
)
|
||||
]
|
||||
return [
|
||||
ConfigAlert(
|
||||
id="telegram_api_error",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
params={"error": str(exc)[:300]},
|
||||
)
|
||||
]
|
||||
|
||||
alerts: List[ConfigAlert] = []
|
||||
actual_url = str(getattr(info, "url", "") or "")
|
||||
base_url = str(getattr(settings, "WEBHOOK_BASE_URL", "") or "").rstrip("/")
|
||||
expected_url = (
|
||||
f"{base_url}{getattr(settings, 'telegram_webhook_path', '/tg/webhook')}" if base_url else ""
|
||||
)
|
||||
if not actual_url:
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="telegram_webhook_missing",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
)
|
||||
)
|
||||
elif expected_url and actual_url != expected_url:
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="telegram_webhook_mismatch",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
params={"actual": actual_url, "expected": expected_url},
|
||||
)
|
||||
)
|
||||
|
||||
pending = int(getattr(info, "pending_update_count", 0) or 0)
|
||||
last_error_date = getattr(info, "last_error_date", None)
|
||||
last_error_ts: Optional[float] = None
|
||||
if last_error_date is not None:
|
||||
last_error_ts = (
|
||||
last_error_date.timestamp()
|
||||
if hasattr(last_error_date, "timestamp")
|
||||
else float(last_error_date)
|
||||
)
|
||||
if (
|
||||
pending > 0
|
||||
and last_error_ts
|
||||
and (time.time() - last_error_ts) < _WEBHOOK_ERROR_RECENT_SECONDS
|
||||
):
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="telegram_webhook_error",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
params={"error": str(getattr(info, "last_error_message", "") or "")[:300]},
|
||||
)
|
||||
)
|
||||
|
||||
if pending > _WEBHOOK_PENDING_THRESHOLD:
|
||||
alerts.append(
|
||||
ConfigAlert(
|
||||
id="telegram_webhook_pending",
|
||||
severity=SEVERITY_WARNING,
|
||||
sections=(SECTION_SETTINGS,),
|
||||
params={"count": pending},
|
||||
)
|
||||
)
|
||||
return alerts
|
||||
|
||||
|
||||
async def panel_alerts(panel_service: Any, settings: Any) -> List[ConfigAlert]:
|
||||
if not getattr(settings, "PANEL_API_URL", None) or not getattr(settings, "PANEL_API_KEY", None):
|
||||
return [
|
||||
ConfigAlert(
|
||||
id="panel_api_not_configured",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=(SECTION_SETTINGS, SECTION_USERS, SECTION_TARIFFS),
|
||||
)
|
||||
]
|
||||
if panel_service is None:
|
||||
return []
|
||||
try:
|
||||
stats = await asyncio.wait_for(
|
||||
panel_service.get_system_stats(), timeout=NETWORK_CHECK_TIMEOUT_SECONDS
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Panel health check failed: %s", exc)
|
||||
stats = None
|
||||
if stats is None:
|
||||
return [
|
||||
ConfigAlert(
|
||||
id="panel_api_unreachable",
|
||||
severity=SEVERITY_ERROR,
|
||||
sections=(SECTION_SETTINGS, SECTION_USERS),
|
||||
params={"url": str(getattr(settings, "PANEL_API_URL", "") or "")},
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
# ─── Aggregation ───────────────────────────────────────────────────
|
||||
|
||||
_network_cache: Dict[int, Tuple[float, List[ConfigAlert]]] = {}
|
||||
_network_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def local_alerts(request: Any, settings: Any, app: Any) -> List[ConfigAlert]:
|
||||
alerts: List[ConfigAlert] = []
|
||||
for collect in (
|
||||
lambda: data_dir_alerts(settings),
|
||||
lambda: config_file_alerts(settings),
|
||||
lambda: payment_provider_alerts(settings, app),
|
||||
lambda: settings_alerts(settings),
|
||||
lambda: proxy_alerts(request, settings),
|
||||
):
|
||||
try:
|
||||
alerts.extend(collect())
|
||||
except Exception: # pragma: no cover - one broken check must not hide others
|
||||
logger.exception("Config health check failed")
|
||||
return alerts
|
||||
|
||||
|
||||
async def network_alerts(app: Any, settings: Any, *, refresh: bool = False) -> List[ConfigAlert]:
|
||||
cache_key = id(settings)
|
||||
now = time.monotonic()
|
||||
if not refresh:
|
||||
cached = _network_cache.get(cache_key)
|
||||
if cached and (now - cached[0]) < NETWORK_CHECKS_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
async with _network_cache_lock:
|
||||
if not refresh:
|
||||
cached = _network_cache.get(cache_key)
|
||||
if cached and (time.monotonic() - cached[0]) < NETWORK_CHECKS_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
results = await asyncio.gather(
|
||||
telegram_alerts(app.get("bot"), settings),
|
||||
panel_alerts(app.get("panel_service"), settings),
|
||||
return_exceptions=True,
|
||||
)
|
||||
alerts: List[ConfigAlert] = []
|
||||
for result in results:
|
||||
if isinstance(result, BaseException):
|
||||
logger.exception("Network config health check failed", exc_info=result)
|
||||
continue
|
||||
alerts.extend(result)
|
||||
_network_cache[cache_key] = (time.monotonic(), alerts)
|
||||
return alerts
|
||||
|
||||
|
||||
async def collect_config_alerts(request: Any, *, refresh: bool = False) -> List[Dict[str, Any]]:
|
||||
app = request.app
|
||||
settings = app["settings"]
|
||||
alerts = local_alerts(request, settings, app)
|
||||
alerts.extend(await network_alerts(app, settings, refresh=refresh))
|
||||
order = {SEVERITY_ERROR: 0, SEVERITY_WARNING: 1}
|
||||
alerts.sort(key=lambda alert: (order.get(alert.severity, 2), alert.id))
|
||||
return [alert.as_payload() for alert in alerts]
|
||||
@@ -10,13 +10,13 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Optional
|
||||
from typing import Optional, Sequence
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.email_templates import EmailContent, render_login_code
|
||||
from bot.services.email_templates import EmailContent, EmailInlineImage, render_login_code
|
||||
from bot.services.message_audit import log_user_message_delivery
|
||||
from config.settings import Settings
|
||||
from db.dal import security_dal, user_dal
|
||||
@@ -61,11 +61,39 @@ def normalize_email(value: str) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def email_domain(value: Optional[str]) -> str:
|
||||
email = normalize_email(value or "")
|
||||
if "@" not in email:
|
||||
return ""
|
||||
return email.rsplit("@", 1)[1].strip().lower().rstrip(".")
|
||||
|
||||
|
||||
def is_valid_email(value: str) -> bool:
|
||||
email = normalize_email(value)
|
||||
return bool(email and len(email) <= 254 and EMAIL_RE.match(email))
|
||||
|
||||
|
||||
def _split_disposable_domain_values(value: str) -> list[str]:
|
||||
return [item.strip() for item in re.split(r"[,;\s]+", value or "") if item.strip()]
|
||||
|
||||
|
||||
def is_disposable_email(value: Optional[str], settings: Settings) -> bool:
|
||||
domain = email_domain(value)
|
||||
if not domain:
|
||||
return False
|
||||
blocked_domains = getattr(settings, "disposable_email_domains", None)
|
||||
if blocked_domains is None:
|
||||
blocked_domains = _split_disposable_domain_values(
|
||||
str(getattr(settings, "DISPOSABLE_EMAIL_DOMAINS", "") or "")
|
||||
)
|
||||
blocked_domains = blocked_domains or []
|
||||
for blocked in blocked_domains:
|
||||
normalized = str(blocked or "").strip().lower().lstrip("@.")
|
||||
if normalized and (domain == normalized or domain.endswith(f".{normalized}")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _email_throttle_identifier(email: str, purpose: str, target_user_id: Optional[int]) -> str:
|
||||
target_part = "none" if target_user_id is None else str(target_user_id)
|
||||
return f"{purpose}:{target_part}:{email}"
|
||||
@@ -453,6 +481,7 @@ class EmailAuthService:
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
inline_images: Sequence[EmailInlineImage] = (),
|
||||
) -> None:
|
||||
await asyncio.to_thread(
|
||||
self._send_custom_email_sync,
|
||||
@@ -460,6 +489,7 @@ class EmailAuthService:
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
inline_images=inline_images,
|
||||
)
|
||||
|
||||
async def send_rendered_email(
|
||||
@@ -473,6 +503,7 @@ class EmailAuthService:
|
||||
subject=content.subject,
|
||||
body=content.text,
|
||||
html_body=content.html,
|
||||
inline_images=content.inline_images,
|
||||
)
|
||||
|
||||
def _send_code_email_sync(
|
||||
@@ -493,17 +524,13 @@ class EmailAuthService:
|
||||
i18n=self.i18n,
|
||||
)
|
||||
|
||||
message = EmailMessage()
|
||||
message["Subject"] = content.subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
self.settings.SMTP_FROM_EMAIL or "",
|
||||
)
|
||||
message = self._build_email_message(
|
||||
email=email,
|
||||
subject=content.subject,
|
||||
body=content.text,
|
||||
html_body=content.html,
|
||||
inline_images=content.inline_images,
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(content.text)
|
||||
message.add_alternative(content.html, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
@@ -554,19 +581,15 @@ class EmailAuthService:
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
inline_images: Sequence[EmailInlineImage] = (),
|
||||
) -> None:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
self.settings.SMTP_FROM_EMAIL or "",
|
||||
)
|
||||
message = self._build_email_message(
|
||||
email=email,
|
||||
subject=subject,
|
||||
body=body,
|
||||
html_body=html_body,
|
||||
inline_images=inline_images,
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
|
||||
context = ssl.create_default_context()
|
||||
smtp_host = self.settings.SMTP_HOST
|
||||
@@ -610,6 +633,64 @@ class EmailAuthService:
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
def _build_email_message(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
html_body: Optional[str] = None,
|
||||
inline_images: Sequence[EmailInlineImage] = (),
|
||||
) -> EmailMessage:
|
||||
message = EmailMessage()
|
||||
message["Subject"] = subject
|
||||
message["From"] = formataddr(
|
||||
(
|
||||
self.settings.SMTP_FROM_NAME or self.settings.WEBAPP_TITLE,
|
||||
self.settings.SMTP_FROM_EMAIL or "",
|
||||
)
|
||||
)
|
||||
message["To"] = email
|
||||
message.set_content(body)
|
||||
if html_body:
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
self._attach_inline_images(message, inline_images)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _attach_inline_images(
|
||||
message: EmailMessage,
|
||||
inline_images: Sequence[EmailInlineImage],
|
||||
) -> None:
|
||||
if not inline_images:
|
||||
return
|
||||
html_part = message.get_body(("html",))
|
||||
if html_part is None:
|
||||
return
|
||||
|
||||
for image in inline_images:
|
||||
content_type = (image.content_type or "").split(";", 1)[0].strip().lower()
|
||||
if "/" not in content_type:
|
||||
continue
|
||||
maintype, subtype = content_type.split("/", 1)
|
||||
if maintype != "image" or not subtype:
|
||||
continue
|
||||
|
||||
body = bytes(image.data or b"")
|
||||
content_id = (image.content_id or "").strip()
|
||||
if not body or not content_id:
|
||||
continue
|
||||
|
||||
cid_header = content_id
|
||||
if not (cid_header.startswith("<") and cid_header.endswith(">")):
|
||||
cid_header = f"<{cid_header}>"
|
||||
html_part.add_related(
|
||||
body,
|
||||
maintype=maintype,
|
||||
subtype=subtype,
|
||||
cid=cid_header,
|
||||
)
|
||||
|
||||
def _send_message_via_smtp(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -10,8 +10,10 @@ copy goes through the shared `JsonI18n` instance so translations live in
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import io
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Optional, Sequence, Tuple
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
@@ -27,6 +29,28 @@ _TEXT_MUTED = "#9aa3b2"
|
||||
_TEXT_DIM = "#5d6573"
|
||||
_DEFAULT_ACCENT = "#00fe7a"
|
||||
_HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
||||
_EMAIL_LOGO_CONTENT_ID = "webapp-logo"
|
||||
_WEBAPP_UPLOADED_LOGO_PATH = "/webapp-uploaded-logo"
|
||||
_WEBAPP_UPLOADED_LOGO_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-logo" / "uploads"
|
||||
_WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
|
||||
_UPLOADED_LOGO_RE = re.compile(r"logo-[0-9a-f]{16}\.(?:gif|ico|jpe?g|png|svg|webp)")
|
||||
_LOGO_CONTENT_TYPES = {
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
}
|
||||
_EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS = {".ico", ".webp"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailInlineImage:
|
||||
content_id: str
|
||||
content_type: str
|
||||
data: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -34,6 +58,13 @@ class EmailContent:
|
||||
subject: str
|
||||
text: str
|
||||
html: str
|
||||
inline_images: Tuple[EmailInlineImage, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _EmailLayout:
|
||||
html: str
|
||||
inline_images: Tuple[EmailInlineImage, ...] = ()
|
||||
|
||||
|
||||
def _safe_color(value: Optional[str]) -> str:
|
||||
@@ -45,11 +76,22 @@ def _safe_color(value: Optional[str]) -> str:
|
||||
return _DEFAULT_ACCENT
|
||||
|
||||
|
||||
def _theme_accent(settings: Settings) -> str:
|
||||
primary = _safe_color(getattr(settings, "WEBAPP_PRIMARY_COLOR", None))
|
||||
try:
|
||||
catalog = getattr(settings, "webapp_themes_catalog", None)
|
||||
if catalog is None:
|
||||
return primary
|
||||
from config.webapp_themes_config import effective_webapp_theme_accent
|
||||
|
||||
return _safe_color(effective_webapp_theme_accent(catalog, primary))
|
||||
except Exception:
|
||||
return primary
|
||||
|
||||
|
||||
def _public_logo_url(settings: Settings) -> Optional[str]:
|
||||
"""Email recipients can't reach the in-app /webapp-logo proxy, so only a
|
||||
stored public https URL can be used directly. Anything else is dropped."""
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
return None
|
||||
raw = (settings.WEBAPP_LOGO_URL or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
@@ -59,6 +101,91 @@ def _public_logo_url(settings: Settings) -> Optional[str]:
|
||||
return raw
|
||||
|
||||
|
||||
def _uploaded_logo_filename(url: str) -> Optional[str]:
|
||||
parsed = urlsplit(str(url or ""))
|
||||
path = parsed.path if parsed.scheme or parsed.netloc else str(url or "")
|
||||
prefix = f"{_WEBAPP_UPLOADED_LOGO_PATH}/"
|
||||
if not path.startswith(prefix):
|
||||
return None
|
||||
filename = path.removeprefix(prefix)
|
||||
return filename if _UPLOADED_LOGO_RE.fullmatch(filename) else None
|
||||
|
||||
|
||||
def _inline_uploaded_logo(settings: Settings) -> Optional[EmailInlineImage]:
|
||||
filename = _uploaded_logo_filename((settings.WEBAPP_LOGO_URL or "").strip())
|
||||
if not filename:
|
||||
return None
|
||||
|
||||
content_type = _LOGO_CONTENT_TYPES.get(Path(filename).suffix.lower())
|
||||
if not content_type:
|
||||
return None
|
||||
|
||||
try:
|
||||
uploads_dir = _WEBAPP_UPLOADED_LOGO_DIR.resolve()
|
||||
logo_path = (uploads_dir / filename).resolve()
|
||||
logo_path.relative_to(uploads_dir)
|
||||
body = logo_path.read_bytes()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
if not body or len(body) > _WEBAPP_LOGO_MAX_BYTES:
|
||||
return None
|
||||
|
||||
content_type, body = _email_logo_payload(filename, content_type, body)
|
||||
|
||||
return EmailInlineImage(
|
||||
content_id=_EMAIL_LOGO_CONTENT_ID,
|
||||
content_type=content_type,
|
||||
data=body,
|
||||
)
|
||||
|
||||
|
||||
def _email_logo_payload(filename: str, content_type: str, body: bytes) -> Tuple[str, bytes]:
|
||||
suffix = Path(filename).suffix.lower()
|
||||
if suffix not in _EMAIL_LOGO_PNG_FALLBACK_EXTENSIONS:
|
||||
return content_type, body
|
||||
|
||||
png_body = _static_raster_logo_to_png(body)
|
||||
if png_body and len(png_body) <= _WEBAPP_LOGO_MAX_BYTES:
|
||||
return "image/png", png_body
|
||||
return content_type, body
|
||||
|
||||
|
||||
def _static_raster_logo_to_png(body: bytes) -> Optional[bytes]:
|
||||
try:
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
with Image.open(io.BytesIO(body)) as image:
|
||||
image.seek(0)
|
||||
if getattr(image, "is_animated", False):
|
||||
return None
|
||||
source = ImageOps.exif_transpose(image).convert("RGBA")
|
||||
except (OSError, UnidentifiedImageError, ValueError, EOFError):
|
||||
return None
|
||||
|
||||
if source.width < 1 or source.height < 1 or source.width > 8192 or source.height > 8192:
|
||||
return None
|
||||
|
||||
output = io.BytesIO()
|
||||
source.save(output, format="PNG", optimize=True)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _email_logo(settings: Settings) -> Tuple[Optional[str], Tuple[EmailInlineImage, ...]]:
|
||||
inline_logo = _inline_uploaded_logo(settings)
|
||||
if inline_logo:
|
||||
return f"cid:{inline_logo.content_id}", (inline_logo,)
|
||||
|
||||
public_url = _public_logo_url(settings)
|
||||
if public_url:
|
||||
return public_url, ()
|
||||
|
||||
return None, ()
|
||||
|
||||
|
||||
def _brand_title(settings: Settings) -> str:
|
||||
title = (settings.WEBAPP_TITLE or "").strip()
|
||||
return title or "Subscription"
|
||||
@@ -98,20 +225,21 @@ def _layout(
|
||||
intro_html: str,
|
||||
body_html: str,
|
||||
footer_html: str,
|
||||
) -> str:
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
accent: Optional[str] = None,
|
||||
) -> _EmailLayout:
|
||||
accent = _safe_color(accent) if accent else _theme_accent(settings)
|
||||
brand_title = html.escape(_brand_title(settings))
|
||||
logo_url = _public_logo_url(settings)
|
||||
logo_url, inline_images = _email_logo(settings)
|
||||
html_lang = html.escape((language_code or "en").replace("_", "-"), quote=True)
|
||||
logo_block = ""
|
||||
if logo_url:
|
||||
logo_block = (
|
||||
f'<img src="{html.escape(logo_url, quote=True)}" width="64" height="64" '
|
||||
f'alt="" style="display:block;border:0;outline:none;text-decoration:none;'
|
||||
f'border-radius:16px;">'
|
||||
f'border-radius:16px;background:transparent;background-color:transparent;">'
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
layout_html = f"""<!DOCTYPE html>
|
||||
<html lang="{html_lang}" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@@ -151,6 +279,16 @@ def _layout(
|
||||
</body>
|
||||
</html>
|
||||
""" # noqa: E501
|
||||
return _EmailLayout(html=layout_html, inline_images=inline_images)
|
||||
|
||||
|
||||
def _email_content(*, subject: str, text: str, layout: _EmailLayout) -> EmailContent:
|
||||
return EmailContent(
|
||||
subject=subject,
|
||||
text=text,
|
||||
html=layout.html,
|
||||
inline_images=layout.inline_images,
|
||||
)
|
||||
|
||||
|
||||
def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str:
|
||||
@@ -258,7 +396,7 @@ def render_login_code(
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
minutes = _format_minutes(settings.EMAIL_CODE_TTL_SECONDS)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
accent = _theme_accent(settings)
|
||||
brand = _brand_title(settings)
|
||||
template_prefix = "email_set_password_code" if purpose == "set_password" else "email_login_code"
|
||||
safe_magic_link = (magic_link or "").strip() if template_prefix == "email_login_code" else ""
|
||||
@@ -318,8 +456,9 @@ def render_login_code(
|
||||
intro_html=html.escape(intro),
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
accent=accent,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def render_account_merged(
|
||||
@@ -372,7 +511,7 @@ def render_account_merged(
|
||||
body_html=body_html,
|
||||
footer_html=footer,
|
||||
)
|
||||
return EmailContent(subject=subject, text=text, html=rendered)
|
||||
return _email_content(subject=subject, text=text, layout=rendered)
|
||||
|
||||
|
||||
def render_payment_success(
|
||||
@@ -391,7 +530,7 @@ def render_payment_success(
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
accent = _theme_accent(settings)
|
||||
brand = _brand_title(settings)
|
||||
sale_base = (sale_mode or "").split("@", 1)[0].split("|", 1)[0]
|
||||
is_traffic = sale_base in {
|
||||
@@ -505,8 +644,9 @@ def render_payment_success(
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
accent=accent,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def render_user_notification(
|
||||
@@ -523,7 +663,7 @@ def render_user_notification(
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
accent = _theme_accent(settings)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
final_subject = (subject or "").strip() or _t_text(
|
||||
@@ -556,6 +696,7 @@ def render_user_notification(
|
||||
intro_html=html.escape(final_intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
accent=accent,
|
||||
)
|
||||
text_lines = [final_subject, "", _telegram_html_to_text(message_text)]
|
||||
if safe_dashboard_url:
|
||||
@@ -567,7 +708,7 @@ def render_user_notification(
|
||||
),
|
||||
]
|
||||
)
|
||||
return EmailContent(subject=final_subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=final_subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def render_subscription_expiring(
|
||||
@@ -581,7 +722,7 @@ def render_subscription_expiring(
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
accent = _theme_accent(settings)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
days = max(0, int(days_left))
|
||||
@@ -630,8 +771,9 @@ def render_subscription_expiring(
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
accent=accent,
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def _subscription_lifecycle_title(
|
||||
@@ -678,7 +820,7 @@ def render_subscription_lifecycle_notification(
|
||||
) -> EmailContent:
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language_code, settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
accent = _theme_accent(settings)
|
||||
brand = _brand_title(settings)
|
||||
safe_dashboard_url = (dashboard_url or "").strip()
|
||||
end_date = end_date_text or "—"
|
||||
@@ -718,6 +860,7 @@ def render_subscription_lifecycle_notification(
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
accent=accent,
|
||||
)
|
||||
|
||||
text_lines = [subject, "", message_text]
|
||||
@@ -733,7 +876,7 @@ def render_subscription_lifecycle_notification(
|
||||
),
|
||||
]
|
||||
)
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def _support_email(
|
||||
@@ -752,7 +895,7 @@ def _support_email(
|
||||
i18n = _resolve_i18n(i18n)
|
||||
lang = _normalize_lang(language, settings)
|
||||
brand = _brand_title(settings)
|
||||
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
|
||||
accent = _theme_accent(settings)
|
||||
safe_url = (ticket_url or "").strip()
|
||||
footer = _t_html(i18n, lang, "email_footer_auto", brand=brand)
|
||||
localized_rows = [
|
||||
@@ -775,6 +918,7 @@ def _support_email(
|
||||
intro_html=html.escape(intro),
|
||||
body_html="".join(body_parts),
|
||||
footer_html=footer,
|
||||
accent=accent,
|
||||
)
|
||||
text_lines = [
|
||||
intro,
|
||||
@@ -785,7 +929,7 @@ def _support_email(
|
||||
]
|
||||
if safe_url:
|
||||
text_lines.extend(["", safe_url])
|
||||
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
|
||||
return _email_content(subject=subject, text="\n".join(text_lines), layout=rendered)
|
||||
|
||||
|
||||
def render_support_new_ticket_admin(
|
||||
|
||||
@@ -108,6 +108,7 @@ LOCALE_GROUPS = [
|
||||
"prefixes": (
|
||||
"admin_user_",
|
||||
"admin_users_",
|
||||
"admin_hwid_",
|
||||
"admin_ban_",
|
||||
"admin_unban_",
|
||||
"admin_banned_",
|
||||
@@ -115,6 +116,7 @@ LOCALE_GROUPS = [
|
||||
"admin_traffic_grant_",
|
||||
"admin_view_banned_",
|
||||
"user_card_",
|
||||
"user_hwid_",
|
||||
"user_premium_",
|
||||
"user_regular_",
|
||||
"user_traffic_",
|
||||
@@ -203,7 +205,6 @@ LOCALE_GROUPS = [
|
||||
"admin_settings_field_subscription_mini_app_url",
|
||||
"admin_settings_field_support_link",
|
||||
"admin_settings_field_server_status_url",
|
||||
"admin_settings_field_terms_",
|
||||
"admin_settings_field_privacy_",
|
||||
"admin_settings_field_user_agreement_",
|
||||
"appearance_",
|
||||
|
||||
@@ -237,11 +237,12 @@ class NotificationService:
|
||||
text: str,
|
||||
path: str,
|
||||
fallback_url: str,
|
||||
web_app_button: bool = True,
|
||||
) -> InlineKeyboardButton:
|
||||
webapp_url = self._support_webapp_url(path)
|
||||
if webapp_url:
|
||||
if webapp_url and web_app_button:
|
||||
return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=webapp_url))
|
||||
return InlineKeyboardButton(text=text, url=fallback_url)
|
||||
return InlineKeyboardButton(text=text, url=webapp_url or fallback_url)
|
||||
|
||||
def _support_text(self, language: Optional[str], key: str, fallback: str) -> str:
|
||||
if not self.i18n:
|
||||
@@ -316,7 +317,14 @@ class NotificationService:
|
||||
return enabled
|
||||
return self._coerce_bool_setting(raw_value, enabled)
|
||||
|
||||
def _support_keyboard(self, ticket, user, *, admin: bool = True) -> InlineKeyboardMarkup:
|
||||
def _support_keyboard(
|
||||
self,
|
||||
ticket,
|
||||
user,
|
||||
*,
|
||||
admin: bool = True,
|
||||
web_app_buttons: bool = True,
|
||||
) -> InlineKeyboardMarkup:
|
||||
ticket_path = (
|
||||
f"/admin/support/{ticket.ticket_id}" if admin else f"/support/{ticket.ticket_id}"
|
||||
)
|
||||
@@ -326,6 +334,7 @@ class NotificationService:
|
||||
text="Открыть тикет",
|
||||
path=ticket_path,
|
||||
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=admin),
|
||||
web_app_button=web_app_buttons,
|
||||
)
|
||||
]
|
||||
]
|
||||
@@ -342,12 +351,35 @@ class NotificationService:
|
||||
text="Карточка пользователя",
|
||||
path=user_card_path,
|
||||
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=True),
|
||||
web_app_button=web_app_buttons,
|
||||
)
|
||||
)
|
||||
if profile_row:
|
||||
rows.append(profile_row)
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
def _support_log_thread_id(self) -> Optional[int]:
|
||||
return getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None)
|
||||
|
||||
def _support_thread_is_configured(self) -> bool:
|
||||
return bool(getattr(self.settings, "LOG_CHAT_ID", None) and self._support_log_thread_id())
|
||||
|
||||
async def _send_admin_support_telegram(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
admin_markup: InlineKeyboardMarkup,
|
||||
log_markup: InlineKeyboardMarkup,
|
||||
) -> None:
|
||||
thread_id = self._support_log_thread_id()
|
||||
if not self._support_thread_is_configured():
|
||||
await self._send_to_admins(message, reply_markup=admin_markup)
|
||||
await self._send_to_log_channel(
|
||||
message,
|
||||
thread_id=thread_id,
|
||||
reply_markup=log_markup,
|
||||
)
|
||||
|
||||
def _support_user_keyboard(self, ticket, user) -> InlineKeyboardMarkup:
|
||||
button_text = self._support_text(
|
||||
getattr(user, "language_code", None),
|
||||
@@ -415,12 +447,12 @@ class NotificationService:
|
||||
f"статус: {hd.quote(str(snapshot.get('panel_status') or '—'))}\n\n"
|
||||
f"<b>Текст обращения</b>\n{hd.quote(preview)}"
|
||||
)
|
||||
keyboard = self._support_keyboard(ticket, user, admin=True)
|
||||
await self._send_to_admins(message, reply_markup=keyboard)
|
||||
await self._send_to_log_channel(
|
||||
admin_keyboard = self._support_keyboard(ticket, user, admin=True)
|
||||
log_keyboard = self._support_keyboard(ticket, user, admin=True, web_app_buttons=False)
|
||||
await self._send_admin_support_telegram(
|
||||
message,
|
||||
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
|
||||
reply_markup=keyboard,
|
||||
admin_markup=admin_keyboard,
|
||||
log_markup=log_keyboard,
|
||||
)
|
||||
await self._send_admin_support_email(
|
||||
render_support_new_ticket_admin,
|
||||
@@ -456,13 +488,13 @@ class NotificationService:
|
||||
f"💬 <b>Ответ пользователя в тикете #{ticket.ticket_id}</b>\n"
|
||||
f"{hd.quote(user_display)}{unread_line}\n\n{hd.quote(preview)}"
|
||||
)
|
||||
keyboard = self._support_keyboard(ticket, user, admin=True)
|
||||
if send_telegram and getattr(self.settings, "LOG_SUPPORT", True):
|
||||
await self._send_to_admins(text, reply_markup=keyboard)
|
||||
await self._send_to_log_channel(
|
||||
admin_keyboard = self._support_keyboard(ticket, user, admin=True)
|
||||
log_keyboard = self._support_keyboard(ticket, user, admin=True, web_app_buttons=False)
|
||||
await self._send_admin_support_telegram(
|
||||
text,
|
||||
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
|
||||
reply_markup=keyboard,
|
||||
admin_markup=admin_keyboard,
|
||||
log_markup=log_keyboard,
|
||||
)
|
||||
if send_email:
|
||||
await self._send_admin_support_email(
|
||||
|
||||
@@ -15,6 +15,40 @@ from config.settings import Settings
|
||||
from db.dal import panel_sync_dal
|
||||
from db.models import PanelSyncStatus
|
||||
|
||||
# Static endpoint prefixes used as log/metric labels instead of the raw request
|
||||
# path. Endpoints embed user identifiers (telegram id, username, email, uuids),
|
||||
# so logging the path verbatim would leak private data into log files; the
|
||||
# label keeps only the constant prefix. Longest prefixes first so e.g.
|
||||
# "/users/by-email/..." does not collapse into "/users".
|
||||
_ENDPOINT_LOG_LABELS = (
|
||||
"/users/by-telegram-id",
|
||||
"/users/by-username",
|
||||
"/users/by-email",
|
||||
"/users",
|
||||
"/subscriptions/subpage-config",
|
||||
"/subscription-page-configs",
|
||||
"/hwid/devices/delete",
|
||||
"/hwid/devices",
|
||||
"/system/stats/bandwidth",
|
||||
"/system/stats/nodes",
|
||||
"/system/stats",
|
||||
"/system/tools/happ/encrypt",
|
||||
"/bandwidth-stats/users",
|
||||
"/bandwidth-stats/nodes",
|
||||
"/internal-squads",
|
||||
"/hosts",
|
||||
"/nodes",
|
||||
)
|
||||
|
||||
|
||||
def _endpoint_log_label(endpoint: str) -> str:
|
||||
"""Map a request endpoint to a constant, identifier-free label for logs."""
|
||||
path = "/" + endpoint.split("?", 1)[0].strip("/")
|
||||
for label in _ENDPOINT_LOG_LABELS:
|
||||
if path == label or path.startswith(label + "/"):
|
||||
return label
|
||||
return "/other"
|
||||
|
||||
|
||||
class PanelApiService:
|
||||
# Status codes returned by _request_once for failures we consider transient
|
||||
@@ -22,6 +56,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 +109,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 +189,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_log_label(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
|
||||
@@ -137,6 +214,7 @@ class PanelApiService:
|
||||
headers = await self._prepare_headers()
|
||||
|
||||
url_for_request = f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
endpoint_label = _endpoint_log_label(endpoint)
|
||||
|
||||
current_params = kwargs.get("params")
|
||||
url_with_params_for_log = url_for_request
|
||||
@@ -158,8 +236,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:
|
||||
@@ -169,7 +247,7 @@ class PanelApiService:
|
||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=%s",
|
||||
time.monotonic() - started,
|
||||
method.upper(),
|
||||
endpoint,
|
||||
endpoint_label,
|
||||
response_status,
|
||||
)
|
||||
|
||||
@@ -228,17 +306,67 @@ class PanelApiService:
|
||||
return {"error": True, "status_code": response_status, "details": error_details}
|
||||
|
||||
except aiohttp.ClientConnectorError as e:
|
||||
logging.error(f"Panel API ClientConnectorError to {url_for_request}: {e}")
|
||||
logging.info(
|
||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=connect_error",
|
||||
time.monotonic() - started,
|
||||
method.upper(),
|
||||
endpoint_label,
|
||||
)
|
||||
logging.error(
|
||||
"Panel API ClientConnectorError method=%s endpoint=%s: %s",
|
||||
method.upper(),
|
||||
endpoint_label,
|
||||
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_label,
|
||||
)
|
||||
logging.warning(
|
||||
"Panel API timeout method=%s endpoint=%s: %s", method.upper(), endpoint_label, e
|
||||
)
|
||||
return {"error": True, "status_code": -3, "message": f"Request timed out: {str(e)}"}
|
||||
except aiohttp.ClientError as e:
|
||||
logging.exception("Panel API ClientError to %s.", url_for_request)
|
||||
logging.info(
|
||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=client_error",
|
||||
time.monotonic() - started,
|
||||
method.upper(),
|
||||
endpoint_label,
|
||||
)
|
||||
logging.exception(
|
||||
"Panel API ClientError method=%s endpoint=%s.", method.upper(), endpoint_label
|
||||
)
|
||||
return {"error": True, "status_code": -2, "message": f"Client error: {str(e)}"}
|
||||
except asyncio.TimeoutError:
|
||||
logging.error(f"Panel API request to {url_for_request} timed out.")
|
||||
logging.info(
|
||||
"metric panel_latency_seconds=%.3f method=%s endpoint=%s status=timeout",
|
||||
time.monotonic() - started,
|
||||
method.upper(),
|
||||
endpoint_label,
|
||||
)
|
||||
logging.error(
|
||||
"Panel API request timed out method=%s endpoint=%s.",
|
||||
method.upper(),
|
||||
endpoint_label,
|
||||
)
|
||||
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_label,
|
||||
)
|
||||
logging.error(
|
||||
f"Unexpected Panel API request error to {url_for_request}: {e}", exc_info=True
|
||||
"Unexpected Panel API request error method=%s endpoint=%s: %s",
|
||||
method.upper(),
|
||||
endpoint_label,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
return {"error": True, "status_code": -4, "message": f"Unexpected error: {str(e)}"}
|
||||
|
||||
@@ -885,7 +1013,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)
|
||||
|
||||
@@ -38,8 +38,12 @@ class PromoCodeService:
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, datetime | str]:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = (code_input or "").strip().upper()[:100]
|
||||
code_display = html_escape(code_input_upper[:100], quote=False)
|
||||
preserve_case = bool(
|
||||
getattr(self.settings, "MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED", False)
|
||||
)
|
||||
code_input_clean = (code_input or "").strip()[:100]
|
||||
lookup_code = code_input_clean if preserve_case else code_input_clean.upper()
|
||||
code_display = html_escape(lookup_code[:100], quote=False)
|
||||
throttle_identifier = self._throttle_identifier(user_id)
|
||||
|
||||
throttle = await security_dal.check_throttle(
|
||||
@@ -54,7 +58,7 @@ class PromoCodeService:
|
||||
)
|
||||
|
||||
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
|
||||
session, code_input_upper
|
||||
session, lookup_code, preserve_case=preserve_case
|
||||
)
|
||||
|
||||
if not promo_data:
|
||||
@@ -74,6 +78,8 @@ class PromoCodeService:
|
||||
)
|
||||
return False, _("promo_code_not_found", code=code_display)
|
||||
|
||||
applied_code = str(promo_data.code or lookup_code)
|
||||
code_display = html_escape(applied_code[:100], quote=False)
|
||||
existing_activation = await promo_code_dal.get_user_activation_for_promo(
|
||||
session, promo_data.promo_code_id, user_id
|
||||
)
|
||||
@@ -81,12 +87,17 @@ class PromoCodeService:
|
||||
return False, _("promo_code_already_used_by_user", code=code_display)
|
||||
|
||||
bonus_days = promo_data.bonus_days
|
||||
default_tariff_key = None
|
||||
tariffs_config = getattr(self.settings, "tariffs_config", None)
|
||||
if tariffs_config:
|
||||
default_tariff_key = getattr(tariffs_config, "default_tariff", None)
|
||||
|
||||
new_end_date = await self.subscription_service.extend_active_subscription_days(
|
||||
session=session,
|
||||
user_id=user_id,
|
||||
bonus_days=bonus_days,
|
||||
reason=f"promo code {code_input_upper}",
|
||||
reason=f"promo code {applied_code}",
|
||||
tariff_key=default_tariff_key,
|
||||
)
|
||||
|
||||
if new_end_date:
|
||||
@@ -109,7 +120,7 @@ class PromoCodeService:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
await notification_service.notify_promo_activation(
|
||||
user_id=user_id,
|
||||
promo_code=code_input_upper,
|
||||
promo_code=applied_code,
|
||||
bonus_days=bonus_days,
|
||||
username=user.username if user else None,
|
||||
email=getattr(user, "email", None) if user else None,
|
||||
|
||||
@@ -197,6 +197,9 @@ class ReferralService:
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
# Short bonus grant: warn only hours before it
|
||||
# ends, not days ahead. A real payment clears this.
|
||||
"suppress_early_expiry_notifications": True,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
|
||||
@@ -28,10 +28,7 @@ from db.dal import app_settings_dal
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APPEARANCE_OVERRIDE_KEYS = {
|
||||
"WEBAPP_LOGO_USE_EMOJI",
|
||||
"WEBAPP_LOGO_URL",
|
||||
"WEBAPP_LOGO_EMOJI",
|
||||
"WEBAPP_LOGO_EMOJI_FONT",
|
||||
"WEBAPP_FAVICON_USE_CUSTOM",
|
||||
"WEBAPP_FAVICON_URL",
|
||||
"WEBAPP_LOGO_FAVICON_URL",
|
||||
@@ -167,12 +164,6 @@ def _appearance_snapshot(settings: Settings) -> Dict[str, Any]:
|
||||
snapshot["WEBAPP_FAVICON_URL"] = favicon_url
|
||||
if getattr(settings, "WEBAPP_FAVICON_USE_CUSTOM", False):
|
||||
snapshot["WEBAPP_FAVICON_USE_CUSTOM"] = True
|
||||
if getattr(settings, "WEBAPP_LOGO_USE_EMOJI", False):
|
||||
snapshot["WEBAPP_LOGO_USE_EMOJI"] = True
|
||||
snapshot["WEBAPP_LOGO_EMOJI"] = getattr(settings, "WEBAPP_LOGO_EMOJI", "")
|
||||
emoji_font = getattr(settings, "WEBAPP_LOGO_EMOJI_FONT", "")
|
||||
if emoji_font and emoji_font != "system":
|
||||
snapshot["WEBAPP_LOGO_EMOJI_FONT"] = emoji_font
|
||||
primary_color = getattr(settings, "WEBAPP_PRIMARY_COLOR", None)
|
||||
if primary_color and primary_color != "#00fe7a":
|
||||
snapshot["WEBAPP_PRIMARY_COLOR"] = primary_color
|
||||
|
||||
@@ -153,6 +153,15 @@ class SubscriptionNotificationWorker:
|
||||
hours_before=hours_before,
|
||||
)
|
||||
|
||||
# Trial and registration/referral-bonus subscriptions last only a
|
||||
# few days, so a multi-day "ending soon" reminder would fire almost
|
||||
# the moment they are granted and needlessly alarm newcomers. Skip
|
||||
# the day-before stages for them — they still get the hours-before
|
||||
# reminder above and the expiry/after-expiry notices below. Paying
|
||||
# for a real subscription clears the flag and restores all stages.
|
||||
if bool(getattr(sub, "suppress_early_expiry_notifications", False)):
|
||||
return None
|
||||
|
||||
days_before_limit = max(
|
||||
0,
|
||||
int(getattr(self.settings, "SUBSCRIPTION_NOTIFY_DAYS_BEFORE", 0) or 0),
|
||||
|
||||
@@ -31,6 +31,49 @@ class HwidDeviceMixin:
|
||||
)
|
||||
return int(getattr(sub, "extra_hwid_devices", 0) or 0)
|
||||
|
||||
async def sync_hwid_device_limit_to_panel(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
) -> Optional[int]:
|
||||
"""Push the current local HWID device limit override to the panel."""
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or not db_user.panel_user_uuid:
|
||||
return None
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, db_user.panel_user_uuid
|
||||
)
|
||||
if not sub:
|
||||
return None
|
||||
|
||||
tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None
|
||||
base_hwid_limit = (
|
||||
int(sub.hwid_device_limit)
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
extra_hwid_devices = await self._active_hwid_extra_devices_for_sub(session, sub)
|
||||
sub.extra_hwid_devices = extra_hwid_devices
|
||||
effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices)
|
||||
if effective_hwid_limit is None:
|
||||
return None
|
||||
|
||||
panel_payload = self._build_panel_update_payload(
|
||||
panel_user_uuid=db_user.panel_user_uuid,
|
||||
expire_at=sub.end_date,
|
||||
status="ACTIVE",
|
||||
hwid_device_limit=effective_hwid_limit,
|
||||
include_default_squads=False,
|
||||
)
|
||||
panel_payload.update(self._panel_identity_payload_for_user(db_user))
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
db_user.panel_user_uuid, panel_payload
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("sync_hwid_device_limit_to_panel failed for user %s", user_id)
|
||||
return effective_hwid_limit
|
||||
|
||||
async def _hwid_topup_validity_window(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -73,6 +116,64 @@ class HwidDeviceMixin:
|
||||
packages = package_set.for_currency(currency)
|
||||
return next((pkg for pkg in packages if int(pkg.count) == int(device_count)), None)
|
||||
|
||||
@staticmethod
|
||||
def _quote_hwid_full_period_package_price(
|
||||
tariff: Tariff,
|
||||
*,
|
||||
device_count: int,
|
||||
period_months: int,
|
||||
currency: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
package_set = tariff.hwid_device_packages
|
||||
if not package_set:
|
||||
return None
|
||||
try:
|
||||
target_count = int(device_count)
|
||||
months = max(1, int(period_months))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if target_count <= 0:
|
||||
return None
|
||||
|
||||
packages = [
|
||||
package
|
||||
for package in package_set.for_currency(currency)
|
||||
if int(getattr(package, "count", 0) or 0) > 0
|
||||
]
|
||||
if not packages:
|
||||
return None
|
||||
|
||||
best: Dict[int, tuple[float, List[Any]]] = {0: (0.0, [])}
|
||||
for count in range(1, target_count + 1):
|
||||
best_for_count: Optional[tuple[float, List[Any]]] = None
|
||||
for package in packages:
|
||||
package_count = int(package.count)
|
||||
previous = best.get(count - package_count)
|
||||
if previous is None:
|
||||
continue
|
||||
price = previous[0] + float(package.price_for_period(months))
|
||||
selected = [*previous[1], package]
|
||||
if best_for_count is None or price < best_for_count[0]:
|
||||
best_for_count = (price, selected)
|
||||
if best_for_count is not None:
|
||||
best[count] = best_for_count
|
||||
|
||||
resolved = best.get(target_count)
|
||||
if resolved is None:
|
||||
return None
|
||||
full_price, selected_packages = resolved
|
||||
rounded_price = HwidDeviceMixin._round_hwid_price(full_price, currency=currency)
|
||||
if currency == "stars":
|
||||
rounded_price = float(int(math.ceil(rounded_price)))
|
||||
return {
|
||||
"price": rounded_price,
|
||||
"full_price": float(full_price),
|
||||
"pricing_period_months": months,
|
||||
"proration_ratio": 1.0,
|
||||
"currency": currency,
|
||||
"package_counts": [int(package.count) for package in selected_packages],
|
||||
}
|
||||
|
||||
def _quote_hwid_package_price(
|
||||
self,
|
||||
*,
|
||||
@@ -85,16 +186,10 @@ class HwidDeviceMixin:
|
||||
) -> Dict[str, Any]:
|
||||
period_months = max(1, int(getattr(sub, "duration_months", None) or 1))
|
||||
full_price = float(package.price_for_period(period_months))
|
||||
period_start = self._as_aware_utc(getattr(sub, "start_date", None))
|
||||
period_end = self._as_aware_utc(getattr(sub, "end_date", None)) or valid_until
|
||||
inferred_period_start = add_months(period_end, -period_months)
|
||||
if not period_start or period_start >= period_end or period_start < inferred_period_start:
|
||||
period_start = inferred_period_start
|
||||
|
||||
basis_seconds = max(1.0, (period_end - period_start).total_seconds())
|
||||
basis_seconds = max(1.0, float(period_months * 30 * 24 * 60 * 60))
|
||||
billable_start = max(now, valid_from)
|
||||
billable_seconds = max(0.0, (valid_until - billable_start).total_seconds())
|
||||
ratio = billable_seconds / basis_seconds
|
||||
ratio = min(1.0, billable_seconds / basis_seconds)
|
||||
raw_price = full_price * ratio
|
||||
price = self._round_hwid_price(raw_price, currency=currency)
|
||||
min_price = getattr(package, "min_price", None)
|
||||
@@ -150,7 +245,7 @@ class HwidDeviceMixin:
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
if base_hwid_limit == 0:
|
||||
if base_hwid_limit in (None, 0):
|
||||
return None
|
||||
|
||||
package = self._find_hwid_package(tariff, purchased_devices, currency)
|
||||
@@ -187,6 +282,80 @@ class HwidDeviceMixin:
|
||||
)
|
||||
return quote
|
||||
|
||||
async def quote_hwid_device_renewal_for_subscription(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: int,
|
||||
target_tariff_key: str,
|
||||
months: int,
|
||||
currency: str = "rub",
|
||||
now: Optional[datetime] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
period_months = int(months)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if period_months <= 0:
|
||||
return None
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or not db_user.panel_user_uuid:
|
||||
return None
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, db_user.panel_user_uuid
|
||||
)
|
||||
if not sub or not sub.end_date:
|
||||
return None
|
||||
|
||||
now = now or datetime.now(timezone.utc)
|
||||
subscription_end = self._as_aware_utc(sub.end_date)
|
||||
if not subscription_end or subscription_end <= now:
|
||||
return None
|
||||
|
||||
try:
|
||||
tariff = self._resolve_tariff(target_tariff_key)
|
||||
except Exception:
|
||||
return None
|
||||
if not tariff or tariff.billing_model != "period":
|
||||
return None
|
||||
base_hwid_limit = self._base_hwid_limit_for_tariff(tariff)
|
||||
if base_hwid_limit in (None, 0):
|
||||
return None
|
||||
|
||||
entitlement_summary = await tariff_dal.get_hwid_device_entitlement_summary(
|
||||
session,
|
||||
subscription_id=sub.subscription_id,
|
||||
at=now,
|
||||
)
|
||||
active_devices = int(entitlement_summary.get("active_devices") or 0)
|
||||
if active_devices <= 0:
|
||||
return None
|
||||
|
||||
price_quote = self._quote_hwid_full_period_package_price(
|
||||
tariff,
|
||||
device_count=active_devices,
|
||||
period_months=period_months,
|
||||
currency=currency,
|
||||
)
|
||||
if not price_quote:
|
||||
return None
|
||||
|
||||
valid_from = subscription_end
|
||||
valid_until = add_months(valid_from, period_months)
|
||||
price_quote.update(
|
||||
{
|
||||
"subscription_id": sub.subscription_id,
|
||||
"tariff_key": tariff.key,
|
||||
"device_count": active_devices,
|
||||
"renewal": True,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
"active_until": entitlement_summary.get("active_until"),
|
||||
}
|
||||
)
|
||||
return price_quote
|
||||
|
||||
async def activate_hwid_device_topup(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
@@ -248,7 +417,7 @@ class HwidDeviceMixin:
|
||||
if sub.hwid_device_limit is not None
|
||||
else self._base_hwid_limit_for_tariff(tariff)
|
||||
)
|
||||
if base_hwid_limit == 0:
|
||||
if base_hwid_limit in (None, 0):
|
||||
logging.info(
|
||||
"Skipping HWID top-up for user %s because current limit is unlimited", user_id
|
||||
)
|
||||
|
||||
@@ -178,6 +178,7 @@ class SubscriptionLifecycleMixin:
|
||||
user_id: int,
|
||||
target_tariff_key: str,
|
||||
mode: str,
|
||||
payment_id: Optional[int] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
config = self._tariffs_config()
|
||||
if not config:
|
||||
@@ -336,7 +337,7 @@ class SubscriptionLifecycleMixin:
|
||||
"from_tariff_key": before_tariff_key,
|
||||
"to_tariff_key": target.key,
|
||||
"mode": mode,
|
||||
"payment_id": None,
|
||||
"payment_id": payment_id,
|
||||
"days_before": options.get("remaining_days"),
|
||||
"days_after": (updated.end_date - now).days
|
||||
if updated.end_date and target.billing_model == "period"
|
||||
@@ -454,27 +455,11 @@ class SubscriptionLifecycleMixin:
|
||||
user_id,
|
||||
tariff_key,
|
||||
"paid_diff",
|
||||
payment_id=payment_db_id,
|
||||
)
|
||||
if result:
|
||||
sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id)
|
||||
if sub:
|
||||
await tariff_dal.create_tariff_change(
|
||||
session,
|
||||
{
|
||||
"subscription_id": sub.subscription_id,
|
||||
"from_tariff_key": None,
|
||||
"to_tariff_key": tariff_key,
|
||||
"mode": "paid_diff",
|
||||
"payment_id": payment_db_id,
|
||||
"days_before": None,
|
||||
"days_after": (sub.end_date - datetime.now(timezone.utc)).days
|
||||
if sub.end_date
|
||||
else None,
|
||||
"converted_bytes": None,
|
||||
"eff_price_before": None,
|
||||
"eff_price_after": sub.effective_monthly_price_rub,
|
||||
},
|
||||
)
|
||||
result["end_date"] = sub.end_date
|
||||
result["is_active"] = sub.is_active
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -494,10 +479,29 @@ class SubscriptionLifecycleMixin:
|
||||
await self._record_payment_context(
|
||||
session,
|
||||
payment_db_id,
|
||||
sale_mode=sale_mode_base,
|
||||
sale_mode=sale_mode,
|
||||
tariff_key=tariff.key if tariff else tariff_key,
|
||||
purchased_gb=None,
|
||||
)
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
try:
|
||||
hwid_renewal_devices = int(getattr(payment, "purchased_hwid_devices", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
hwid_renewal_devices = 0
|
||||
try:
|
||||
hwid_renewal_price = (
|
||||
float(getattr(payment, "hwid_full_price", 0) or 0)
|
||||
if hwid_renewal_devices > 0
|
||||
else 0.0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
hwid_renewal_price = 0.0
|
||||
hwid_renewal_valid_from = self._as_aware_utc(
|
||||
getattr(payment, "hwid_valid_from", None) if payment else None
|
||||
)
|
||||
hwid_renewal_valid_until = self._as_aware_utc(
|
||||
getattr(payment, "hwid_valid_until", None) if payment else None
|
||||
)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
@@ -569,6 +573,26 @@ class SubscriptionLifecycleMixin:
|
||||
promo_code_id_from_payment = None
|
||||
|
||||
final_end_date = start_date + timedelta(days=duration_days_total)
|
||||
if hwid_renewal_devices > 0 and hwid_renewal_valid_until and applied_promo_bonus_days:
|
||||
hwid_renewal_valid_until = hwid_renewal_valid_until + timedelta(
|
||||
days=applied_promo_bonus_days
|
||||
)
|
||||
if payment:
|
||||
payment.hwid_valid_until = hwid_renewal_valid_until
|
||||
elif applied_promo_bonus_days > 0 and current_active_sub:
|
||||
try:
|
||||
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session,
|
||||
subscription_id=current_active_sub.subscription_id,
|
||||
at=datetime.now(timezone.utc),
|
||||
subscription_end_before=start_date,
|
||||
delta=timedelta(days=applied_promo_bonus_days),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to extend HWID device purchases for promo payment bonus of user %s",
|
||||
user_id,
|
||||
)
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
@@ -614,7 +638,8 @@ class SubscriptionLifecycleMixin:
|
||||
premium_topup_balance_bytes,
|
||||
premium_topup_used_bytes,
|
||||
)
|
||||
effective_monthly_price = float(payment_amount) / max(1, months_int)
|
||||
subscription_amount_for_pricing = max(0.0, float(payment_amount) - hwid_renewal_price)
|
||||
effective_monthly_price = subscription_amount_for_pricing / max(1, months_int)
|
||||
regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0)
|
||||
regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False))
|
||||
traffic_limit_bytes = self._traffic_limit_for_period_tariff(
|
||||
@@ -641,6 +666,9 @@ class SubscriptionLifecycleMixin:
|
||||
"traffic_limit_bytes": traffic_limit_bytes,
|
||||
"provider": provider,
|
||||
"skip_notifications": False,
|
||||
# A real payment restores the full reminder spectrum, clearing any
|
||||
# trial/bonus suppression carried over on this panel subscription.
|
||||
"suppress_early_expiry_notifications": False,
|
||||
"auto_renew_enabled": auto_renew_should_enable,
|
||||
"tariff_key": tariff.key if tariff else None,
|
||||
"tier_baseline_bytes": tier_baseline_bytes,
|
||||
@@ -695,6 +723,31 @@ class SubscriptionLifecycleMixin:
|
||||
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
hwid_devices_renewed_count = 0
|
||||
hwid_devices_renewed_until = None
|
||||
if hwid_renewal_devices > 0:
|
||||
if (
|
||||
hwid_renewal_valid_from
|
||||
and hwid_renewal_valid_until
|
||||
and hwid_renewal_valid_from < hwid_renewal_valid_until
|
||||
):
|
||||
await tariff_dal.create_hwid_device_purchase(
|
||||
session,
|
||||
subscription_id=new_or_updated_sub.subscription_id,
|
||||
payment_id=payment_db_id,
|
||||
purchased_devices=hwid_renewal_devices,
|
||||
valid_from=hwid_renewal_valid_from,
|
||||
valid_until=hwid_renewal_valid_until,
|
||||
)
|
||||
hwid_devices_renewed_count = hwid_renewal_devices
|
||||
hwid_devices_renewed_until = hwid_renewal_valid_until
|
||||
else:
|
||||
logging.warning(
|
||||
"Skipping HWID renewal purchase for payment %s: invalid window %s -> %s",
|
||||
payment_db_id,
|
||||
hwid_renewal_valid_from,
|
||||
hwid_renewal_valid_until,
|
||||
)
|
||||
|
||||
await self._send_payment_success_email(
|
||||
db_user=db_user,
|
||||
@@ -715,8 +768,12 @@ class SubscriptionLifecycleMixin:
|
||||
"subscription_url": final_subscription_url,
|
||||
"applied_promo_bonus_days": applied_promo_bonus_days,
|
||||
"tariff_key": tariff.key if tariff else None,
|
||||
"hwid_devices_renewal_recommended_count": extra_hwid_devices,
|
||||
"hwid_devices_valid_until": hwid_devices_valid_until,
|
||||
"hwid_devices_renewal_recommended_count": 0
|
||||
if hwid_devices_renewed_count
|
||||
else extra_hwid_devices,
|
||||
"hwid_devices_valid_until": hwid_devices_renewed_until or hwid_devices_valid_until,
|
||||
"hwid_devices_renewed_count": hwid_devices_renewed_count,
|
||||
"hwid_devices_renewed_until": hwid_devices_renewed_until,
|
||||
}
|
||||
|
||||
async def extend_active_subscription_days(
|
||||
@@ -725,6 +782,8 @@ class SubscriptionLifecycleMixin:
|
||||
user_id: int,
|
||||
bonus_days: int,
|
||||
reason: str = "bonus",
|
||||
extend_hwid_devices: bool = True,
|
||||
tariff_key: Optional[str] = None,
|
||||
) -> Optional[datetime]:
|
||||
reason_lower = (reason or "").lower()
|
||||
apply_main_traffic_limit = any(
|
||||
@@ -751,6 +810,17 @@ class SubscriptionLifecycleMixin:
|
||||
preserve_tariff_limits = bool(
|
||||
active_sub and active_sub.tariff_key and self._tariffs_config()
|
||||
)
|
||||
bonus_tariff = None
|
||||
if not active_sub and tariff_key and self._tariffs_config():
|
||||
try:
|
||||
bonus_tariff = self._resolve_tariff(tariff_key)
|
||||
except Exception:
|
||||
logging.warning(
|
||||
"Unable to resolve bonus tariff %s for user %s.",
|
||||
tariff_key,
|
||||
user_id,
|
||||
exc_info=True,
|
||||
)
|
||||
if not active_sub or not active_sub.end_date:
|
||||
logging.info(
|
||||
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501
|
||||
@@ -760,10 +830,16 @@ class SubscriptionLifecycleMixin:
|
||||
|
||||
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise # noqa: E501
|
||||
traffic_limit = (
|
||||
self.settings.user_traffic_limit_bytes
|
||||
self._traffic_limit_for_period_tariff(bonus_tariff)
|
||||
if bonus_tariff
|
||||
else self.settings.user_traffic_limit_bytes
|
||||
if apply_main_traffic_limit
|
||||
else self.settings.trial_traffic_limit_bytes
|
||||
)
|
||||
premium_baseline_bytes = bonus_tariff.premium_monthly_bytes if bonus_tariff else 0
|
||||
base_hwid_limit = (
|
||||
self._base_hwid_limit_for_tariff(bonus_tariff) if bonus_tariff else None
|
||||
)
|
||||
|
||||
bonus_sub_payload = {
|
||||
"user_id": user_id,
|
||||
@@ -776,6 +852,24 @@ class SubscriptionLifecycleMixin:
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": traffic_limit,
|
||||
"auto_renew_enabled": False,
|
||||
"tariff_key": bonus_tariff.key if bonus_tariff else None,
|
||||
"tier_baseline_bytes": bonus_tariff.monthly_bytes if bonus_tariff else None,
|
||||
"topup_balance_bytes": 0,
|
||||
"regular_bonus_bytes": 0,
|
||||
"regular_unlimited_override": False,
|
||||
"premium_baseline_bytes": premium_baseline_bytes,
|
||||
"premium_topup_balance_bytes": 0,
|
||||
"premium_topup_used_bytes": 0,
|
||||
"premium_used_bytes": 0,
|
||||
"premium_is_limited": False,
|
||||
"premium_period_start_at": None,
|
||||
"period_start_at": None,
|
||||
"is_throttled": False,
|
||||
"hwid_device_limit": base_hwid_limit,
|
||||
"extra_hwid_devices": 0,
|
||||
# Registration/referral bonus grants are short-lived, like a
|
||||
# trial: only warn a few hours before they end, not days ahead.
|
||||
"suppress_early_expiry_notifications": True,
|
||||
}
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_uuid
|
||||
@@ -792,6 +886,21 @@ class SubscriptionLifecycleMixin:
|
||||
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
||||
session, active_sub.subscription_id, new_end_date_obj
|
||||
)
|
||||
if updated_sub_model and extend_hwid_devices:
|
||||
try:
|
||||
await tariff_dal.extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session,
|
||||
subscription_id=active_sub.subscription_id,
|
||||
at=now_utc,
|
||||
subscription_end_before=current_end_date,
|
||||
delta=timedelta(days=bonus_days),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to extend HWID device purchases for %s bonus of user %s",
|
||||
reason,
|
||||
user_id,
|
||||
)
|
||||
|
||||
if (
|
||||
apply_main_traffic_limit
|
||||
@@ -810,13 +919,35 @@ class SubscriptionLifecycleMixin:
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
expire_at=new_end_date_obj,
|
||||
traffic_limit_bytes=(
|
||||
self.settings.user_traffic_limit_bytes
|
||||
updated_sub_model.traffic_limit_bytes
|
||||
if bonus_tariff
|
||||
else self.settings.user_traffic_limit_bytes
|
||||
if apply_main_traffic_limit and not preserve_tariff_limits
|
||||
else None
|
||||
),
|
||||
traffic_limit_strategy=(
|
||||
"MONTH"
|
||||
if bonus_tariff and bonus_tariff.billing_model == "period"
|
||||
else self.settings.USER_TRAFFIC_STRATEGY
|
||||
if bonus_tariff
|
||||
else None
|
||||
),
|
||||
hwid_device_limit=(
|
||||
self._effective_hwid_limit(updated_sub_model.hwid_device_limit, 0)
|
||||
if bonus_tariff
|
||||
else None
|
||||
),
|
||||
include_uuid=False,
|
||||
include_default_squads=False,
|
||||
)
|
||||
if bonus_tariff:
|
||||
panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(
|
||||
bonus_tariff
|
||||
)
|
||||
if self.settings.parsed_user_external_squad_uuid:
|
||||
panel_update_payload["externalSquadUuid"] = (
|
||||
self.settings.parsed_user_external_squad_uuid
|
||||
)
|
||||
|
||||
panel_update_success = await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
|
||||
@@ -14,6 +14,7 @@ class PaymentContextMixin:
|
||||
"severpay": "SeverPay",
|
||||
"wata": "Wata",
|
||||
"cryptopay": "Crypto Pay",
|
||||
"paykilla": "PayKilla",
|
||||
"telegram_stars": "Telegram Stars",
|
||||
}
|
||||
|
||||
@@ -38,7 +39,8 @@ class PaymentContextMixin:
|
||||
payment.sale_mode = sale_mode
|
||||
payment.tariff_key = tariff_key
|
||||
payment.purchased_gb = purchased_gb
|
||||
payment.purchased_hwid_devices = purchased_hwid_devices
|
||||
if purchased_hwid_devices is not None:
|
||||
payment.purchased_hwid_devices = purchased_hwid_devices
|
||||
if hwid_valid_from is not None:
|
||||
payment.hwid_valid_from = hwid_valid_from
|
||||
if hwid_valid_until is not None:
|
||||
|
||||
@@ -42,6 +42,8 @@ class RenewalMixin:
|
||||
|
||||
months = sub.duration_months or 1
|
||||
currency = default_payment_currency_code_for_settings(self.settings)
|
||||
tariff_key = str(getattr(sub, "tariff_key", "") or "").strip() or None
|
||||
sale_mode = f"subscription@{tariff_key}" if tariff_key else "subscription"
|
||||
amount = None
|
||||
tariffs_config = (
|
||||
self._tariffs_config() if callable(getattr(self, "_tariffs_config", None)) else None
|
||||
@@ -62,11 +64,55 @@ class RenewalMixin:
|
||||
logging.error(f"Auto-renew price missing for {months} months")
|
||||
return False
|
||||
|
||||
hwid_quote = None
|
||||
quote_hwid_renewal = getattr(
|
||||
self,
|
||||
"quote_hwid_device_renewal_for_subscription",
|
||||
None,
|
||||
)
|
||||
if tariff_key and callable(quote_hwid_renewal):
|
||||
try:
|
||||
hwid_quote = await quote_hwid_renewal(
|
||||
session,
|
||||
user_id=sub.user_id,
|
||||
target_tariff_key=tariff_key,
|
||||
months=int(months),
|
||||
currency=default_currency_key_for_settings(self.settings),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception(
|
||||
"Failed to quote HWID devices for auto-renew user %s",
|
||||
sub.user_id,
|
||||
)
|
||||
hwid_quote = None
|
||||
if hwid_quote:
|
||||
amount = float(amount) + float(hwid_quote.get("price") or 0)
|
||||
|
||||
metadata = {
|
||||
"user_id": str(sub.user_id),
|
||||
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
||||
"subscription_months": str(months),
|
||||
"sale_mode": sale_mode,
|
||||
}
|
||||
if hwid_quote:
|
||||
metadata["hwid_devices"] = str(int(hwid_quote.get("device_count") or 0))
|
||||
for source_key, metadata_key in (
|
||||
("valid_from", "hwid_valid_from"),
|
||||
("valid_until", "hwid_valid_until"),
|
||||
):
|
||||
value = hwid_quote.get(source_key)
|
||||
if value:
|
||||
metadata[metadata_key] = (
|
||||
value.isoformat() if hasattr(value, "isoformat") else str(value)
|
||||
)
|
||||
for key in (
|
||||
"pricing_period_months",
|
||||
"proration_ratio",
|
||||
"full_price",
|
||||
):
|
||||
value = hwid_quote.get(key)
|
||||
if value is not None:
|
||||
metadata[f"hwid_{key}"] = str(value)
|
||||
resp = await yk.create_payment(
|
||||
amount=float(amount),
|
||||
currency=currency,
|
||||
|
||||
@@ -314,7 +314,7 @@ class TariffMixin:
|
||||
@staticmethod
|
||||
def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]:
|
||||
if base_limit is None:
|
||||
return None
|
||||
return 0
|
||||
base_int = max(0, int(base_limit))
|
||||
if base_int == 0:
|
||||
return 0
|
||||
|
||||
@@ -63,6 +63,8 @@ class TrialSubscriptionMixin:
|
||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
"provider": "trial",
|
||||
# Short trial: only warn a few hours before it ends, not days ahead.
|
||||
"suppress_early_expiry_notifications": True,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||
|
||||
@@ -29,7 +29,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from bot.infra.redis import redis_lock
|
||||
from bot.utils.app_version import resolve_app_version, resolve_app_version_tag
|
||||
from bot.utils.app_version import (
|
||||
resolve_app_version,
|
||||
resolve_app_version_tag,
|
||||
resolve_build_provenance,
|
||||
resolve_image_modified,
|
||||
)
|
||||
from config.settings import Settings
|
||||
from db.dal import app_settings_dal, user_dal
|
||||
|
||||
@@ -169,11 +174,15 @@ class TelemetryWorker:
|
||||
|
||||
version = resolve_app_version()
|
||||
version_tag = resolve_app_version_tag()
|
||||
build_provenance = resolve_build_provenance()
|
||||
image_modified = resolve_image_modified()
|
||||
# Person properties (``$set``) snapshot the latest state per install, so
|
||||
# "version breakdown" in PostHog is a person-property breakdown.
|
||||
person_props = {
|
||||
"app_version": version,
|
||||
"app_version_tag": version_tag,
|
||||
"build_provenance": build_provenance,
|
||||
"image_modified": image_modified,
|
||||
"os": platform.system().lower() or "unknown",
|
||||
"arch": platform.machine().lower() or "unknown",
|
||||
"python_version": platform.python_version(),
|
||||
|
||||
@@ -30,6 +30,7 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_user_delete_confirmation = State()
|
||||
waiting_for_premium_override_bonus_gb = State()
|
||||
waiting_for_traffic_grant_gb = State()
|
||||
waiting_for_hwid_device_limit = State()
|
||||
|
||||
# Ads campaigns
|
||||
waiting_for_ad_source = State()
|
||||
|
||||
@@ -6,6 +6,9 @@ the runtime container agree on the value:
|
||||
REMNAWAVE_MINISHOP_VERSION env > .build-version file > live ``git describe``
|
||||
> ``dev+unknown``
|
||||
|
||||
Build provenance is intentionally separate from the version: official release
|
||||
automation stamps official images, while local/fork builds default to custom.
|
||||
|
||||
The same value powers the admin sidebar (web process) and the anonymous
|
||||
telemetry beacon (worker process), so "active installs" and version
|
||||
breakdowns line up across both.
|
||||
@@ -24,6 +27,16 @@ from typing import Optional
|
||||
APP_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
_APP_VERSION_CACHE: Optional[str] = None
|
||||
_APP_BUILD_PROVENANCE_CACHE: Optional[str] = None
|
||||
|
||||
BUILD_PROVENANCE_OFFICIAL = "official"
|
||||
BUILD_PROVENANCE_CUSTOM = "custom"
|
||||
BUILD_PROVENANCE_UNKNOWN = "unknown"
|
||||
_BUILD_PROVENANCE_VALUES = {
|
||||
BUILD_PROVENANCE_OFFICIAL,
|
||||
BUILD_PROVENANCE_CUSTOM,
|
||||
BUILD_PROVENANCE_UNKNOWN,
|
||||
}
|
||||
|
||||
|
||||
def _run_git_command(*args: str) -> str:
|
||||
@@ -87,6 +100,29 @@ def _read_build_file(name: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_build_provenance(raw: str) -> str:
|
||||
value = str(raw or "").strip().lower()
|
||||
if not value:
|
||||
return ""
|
||||
aliases = {
|
||||
"true": BUILD_PROVENANCE_OFFICIAL,
|
||||
"1": BUILD_PROVENANCE_OFFICIAL,
|
||||
"yes": BUILD_PROVENANCE_OFFICIAL,
|
||||
"upstream": BUILD_PROVENANCE_OFFICIAL,
|
||||
"release": BUILD_PROVENANCE_OFFICIAL,
|
||||
"false": BUILD_PROVENANCE_CUSTOM,
|
||||
"0": BUILD_PROVENANCE_CUSTOM,
|
||||
"no": BUILD_PROVENANCE_CUSTOM,
|
||||
"fork": BUILD_PROVENANCE_CUSTOM,
|
||||
"modified": BUILD_PROVENANCE_CUSTOM,
|
||||
"local": BUILD_PROVENANCE_CUSTOM,
|
||||
}
|
||||
value = aliases.get(value, value)
|
||||
if value in _BUILD_PROVENANCE_VALUES:
|
||||
return value
|
||||
return BUILD_PROVENANCE_CUSTOM
|
||||
|
||||
|
||||
def resolve_app_version() -> str:
|
||||
"""Full version string (cached), e.g. ``v3.4.6+gabc1234``."""
|
||||
global _APP_VERSION_CACHE
|
||||
@@ -124,3 +160,28 @@ def resolve_app_version_tag() -> str:
|
||||
if tag:
|
||||
return tag
|
||||
return resolve_app_version()
|
||||
|
||||
|
||||
def resolve_build_provenance() -> str:
|
||||
"""Low-cardinality image provenance: ``official``, ``custom`` or ``unknown``."""
|
||||
global _APP_BUILD_PROVENANCE_CACHE
|
||||
if _APP_BUILD_PROVENANCE_CACHE:
|
||||
return _APP_BUILD_PROVENANCE_CACHE
|
||||
|
||||
env_value = _normalize_build_provenance(os.getenv("REMNAWAVE_MINISHOP_BUILD_PROVENANCE", ""))
|
||||
if env_value:
|
||||
_APP_BUILD_PROVENANCE_CACHE = env_value
|
||||
return env_value
|
||||
|
||||
build_value = _normalize_build_provenance(_read_build_file(".build-provenance"))
|
||||
if build_value:
|
||||
_APP_BUILD_PROVENANCE_CACHE = build_value
|
||||
return build_value
|
||||
|
||||
_APP_BUILD_PROVENANCE_CACHE = BUILD_PROVENANCE_CUSTOM
|
||||
return _APP_BUILD_PROVENANCE_CACHE
|
||||
|
||||
|
||||
def resolve_image_modified() -> bool:
|
||||
"""True for non-official builds, including forks and local rebuilds."""
|
||||
return resolve_build_provenance() != BUILD_PROVENANCE_OFFICIAL
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -34,12 +34,26 @@ def _parse_ip(value: Optional[str]) -> Optional[ipaddress._BaseAddress]:
|
||||
return None
|
||||
|
||||
|
||||
def _last_forwarded_ip(header_value: str) -> Optional[str]:
|
||||
def _forwarded_ips(header_value: str) -> list[ipaddress._BaseAddress]:
|
||||
candidates = [item.strip() for item in header_value.split(",") if item.strip()]
|
||||
if not candidates:
|
||||
return None
|
||||
candidate = candidates[-1]
|
||||
return candidate if _parse_ip(candidate) is not None else None
|
||||
parsed: list[ipaddress._BaseAddress] = []
|
||||
for candidate in candidates:
|
||||
parsed_ip = _parse_ip(candidate)
|
||||
if parsed_ip is not None:
|
||||
parsed.append(parsed_ip)
|
||||
return parsed
|
||||
|
||||
|
||||
def _forwarded_client_ip(
|
||||
forwarded_ips: Sequence[ipaddress._BaseAddress],
|
||||
trusted_networks: Sequence[ipaddress._BaseNetwork],
|
||||
) -> Optional[str]:
|
||||
for forwarded_ip in reversed(forwarded_ips):
|
||||
if not any(forwarded_ip in network for network in trusted_networks):
|
||||
return str(forwarded_ip)
|
||||
if forwarded_ips:
|
||||
return str(forwarded_ips[0])
|
||||
return None
|
||||
|
||||
|
||||
def request_client_ip(
|
||||
@@ -48,20 +62,21 @@ def request_client_ip(
|
||||
trusted_proxies: Optional[Sequence[str] | str] = None,
|
||||
) -> Optional[str]:
|
||||
remote_ip = _parse_ip(request.remote or "")
|
||||
forwarded_for = request.headers.get("X-Forwarded-For", "")
|
||||
forwarded_ips = _forwarded_ips(request.headers.get("X-Forwarded-For", ""))
|
||||
|
||||
if remote_ip and forwarded_for:
|
||||
if remote_ip and forwarded_ips:
|
||||
trusted_networks = parse_ip_entries(trusted_proxies)
|
||||
if any(remote_ip in network for network in trusted_networks):
|
||||
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
||||
forwarded_ip = _forwarded_client_ip(forwarded_ips, trusted_networks)
|
||||
if forwarded_ip:
|
||||
return forwarded_ip
|
||||
|
||||
if remote_ip:
|
||||
return str(remote_ip)
|
||||
|
||||
forwarded_ip = _last_forwarded_ip(forwarded_for)
|
||||
return forwarded_ip
|
||||
if forwarded_ips:
|
||||
return str(forwarded_ips[-1])
|
||||
return None
|
||||
|
||||
|
||||
def ip_in_allowlist(
|
||||
|
||||
@@ -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:
|
||||
|
||||
+201
-37
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@@ -25,7 +26,128 @@ DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN = (
|
||||
def _split_csv(value: Optional[str]) -> List[str]:
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
return [item.strip() for item in re.split(r"[,;\r\n]+", value) if item.strip()]
|
||||
|
||||
|
||||
DEFAULT_DISPOSABLE_EMAIL_DOMAINS = "\n".join(
|
||||
[
|
||||
"10minutemail.com",
|
||||
"10minutemail.net",
|
||||
"10minutemail.org",
|
||||
"20minutemail.com",
|
||||
"33mail.com",
|
||||
"anonbox.net",
|
||||
"anonymbox.com",
|
||||
"armyspy.com",
|
||||
"byom.de",
|
||||
"crazymailing.com",
|
||||
"cuvox.de",
|
||||
"dayrep.com",
|
||||
"deadaddress.com",
|
||||
"dispostable.com",
|
||||
"dodgeit.com",
|
||||
"dodgit.com",
|
||||
"dropmail.me",
|
||||
"easytrashmail.com",
|
||||
"emailfake.com",
|
||||
"emailondeck.com",
|
||||
"emailtemporanea.com",
|
||||
"emailtemporanea.net",
|
||||
"einrot.com",
|
||||
"fakeinbox.com",
|
||||
"filzmail.com",
|
||||
"fleckens.hu",
|
||||
"generator.email",
|
||||
"getairmail.com",
|
||||
"getnada.com",
|
||||
"grr.la",
|
||||
"guerrillamail.biz",
|
||||
"guerrillamail.com",
|
||||
"guerrillamail.de",
|
||||
"guerrillamail.info",
|
||||
"guerrillamail.net",
|
||||
"guerrillamail.org",
|
||||
"guerrillamailblock.com",
|
||||
"gustr.com",
|
||||
"hmamail.com",
|
||||
"incognitomail.org",
|
||||
"inboxbear.com",
|
||||
"jetable.org",
|
||||
"jourrapide.com",
|
||||
"kasmail.com",
|
||||
"mail-temp.com",
|
||||
"mailcatch.com",
|
||||
"maildrop.cc",
|
||||
"mailexpire.com",
|
||||
"mailinator.com",
|
||||
"mailinator.net",
|
||||
"mailinator.org",
|
||||
"mailmetrash.com",
|
||||
"mailnesia.com",
|
||||
"mailnull.com",
|
||||
"mailpoof.com",
|
||||
"mailtothis.com",
|
||||
"mail.tm",
|
||||
"mintemail.com",
|
||||
"mohmal.com",
|
||||
"moakt.com",
|
||||
"mytemp.email",
|
||||
"mytrashmail.com",
|
||||
"nada.email",
|
||||
"no-spam.ws",
|
||||
"pookmail.com",
|
||||
"rhyta.com",
|
||||
"sharklasers.com",
|
||||
"sofort-mail.de",
|
||||
"spam4.me",
|
||||
"spambog.com",
|
||||
"spamdecoy.net",
|
||||
"spamfree24.org",
|
||||
"spamgourmet.com",
|
||||
"spamhole.com",
|
||||
"spam.la",
|
||||
"spammotel.com",
|
||||
"superrito.com",
|
||||
"teleworm.us",
|
||||
"tempail.com",
|
||||
"temp-mail.io",
|
||||
"temp-mail.org",
|
||||
"tempmail.com",
|
||||
"tempmail.dev",
|
||||
"tempmail.net",
|
||||
"tempmailo.com",
|
||||
"temporaryemail.net",
|
||||
"temporary-mail.net",
|
||||
"tempr.email",
|
||||
"throwawaymail.com",
|
||||
"trash-mail.com",
|
||||
"trash-mail.de",
|
||||
"trashmail.com",
|
||||
"trashmail.me",
|
||||
"trashmail.net",
|
||||
"trashmailer.com",
|
||||
"trashymail.com",
|
||||
"weg-werf-email.de",
|
||||
"wegwerfmail.de",
|
||||
"wegwerfmail.net",
|
||||
"wegwerfmail.org",
|
||||
"yomail.info",
|
||||
"yopmail.com",
|
||||
"yopmail.fr",
|
||||
"yopmail.net",
|
||||
]
|
||||
)
|
||||
|
||||
DEFAULT_TRUSTED_PROXIES = ",".join(
|
||||
[
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"fc00::/7",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class DBSettings(BaseModel):
|
||||
@@ -59,9 +181,6 @@ class WebAppSettings(BaseModel):
|
||||
title: str
|
||||
primary_color: str
|
||||
logo_url: Optional[str]
|
||||
logo_use_emoji: bool
|
||||
logo_emoji: str
|
||||
logo_emoji_font: str
|
||||
favicon_use_custom: bool
|
||||
favicon_url: Optional[str]
|
||||
logo_favicon_url: Optional[str]
|
||||
@@ -100,14 +219,31 @@ 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)
|
||||
ADMIN_BROADCAST_AUDIENCE_COUNTS_CACHE_TTL_SECONDS: int = Field(default=30)
|
||||
PROFILE_SYNC_CACHE_TTL_SECONDS: int = Field(default=900)
|
||||
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_INTERVAL_SECONDS: int = Field(default=3600)
|
||||
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES: int = Field(default=104857600)
|
||||
WEBAPP_RATE_LIMIT_TTL_SECONDS: int = Field(default=60)
|
||||
WEBAPP_RATE_LIMIT_MAX_REQUESTS: int = Field(default=30)
|
||||
TELEGRAM_DROP_NON_PRIVATE_UPDATES: bool = Field(default=True)
|
||||
TELEGRAM_ANTIFLOOD_ENABLED: bool = Field(default=True)
|
||||
TELEGRAM_ANTIFLOOD_WINDOW_SECONDS: int = Field(default=60)
|
||||
TELEGRAM_ANTIFLOOD_MAX_UPDATES_PER_WINDOW: int = Field(default=180)
|
||||
TELEGRAM_ANTIFLOOD_MESSAGE_MAX_PER_WINDOW: int = Field(default=120)
|
||||
TELEGRAM_ANTIFLOOD_CALLBACK_MAX_PER_WINDOW: int = Field(default=240)
|
||||
TELEGRAM_ANTIFLOOD_INLINE_MAX_PER_WINDOW: int = Field(default=60)
|
||||
TELEGRAM_ANTIFLOOD_START_MAX_PER_WINDOW: int = Field(default=30)
|
||||
TELEGRAM_ANTIFLOOD_EXPENSIVE_CALLBACK_MAX_PER_WINDOW: int = Field(default=60)
|
||||
TELEGRAM_ACTION_COOLDOWN_ENABLED: bool = Field(default=True)
|
||||
TELEGRAM_PAYMENT_CALLBACK_COOLDOWN_SECONDS: int = Field(default=20)
|
||||
TELEGRAM_TRIAL_CALLBACK_COOLDOWN_SECONDS: int = Field(default=30)
|
||||
WEBHOOK_QUEUE_NAME: str = Field(default="webhook-events")
|
||||
WEBHOOK_QUEUE_CONCURRENCY: int = Field(default=4)
|
||||
WORKER_PANEL_SYNC_INTERVAL_SECONDS: int = Field(default=900)
|
||||
@@ -147,7 +283,6 @@ class Settings(BaseSettings):
|
||||
|
||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
||||
PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
|
||||
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
|
||||
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||
@@ -186,7 +321,7 @@ class Settings(BaseSettings):
|
||||
|
||||
WEBHOOK_BASE_URL: Optional[str] = None
|
||||
TRUSTED_PROXIES: Optional[str] = Field(
|
||||
default="127.0.0.1,::1",
|
||||
default=DEFAULT_TRUSTED_PROXIES,
|
||||
description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", # noqa: E501
|
||||
)
|
||||
|
||||
@@ -194,7 +329,7 @@ class Settings(BaseSettings):
|
||||
STARS_ADMIN_ONLY_ENABLED: bool = Field(default=False)
|
||||
PAYMENT_METHODS_ORDER: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
|
||||
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay,heleket,paykilla)", # noqa: E501
|
||||
)
|
||||
SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED: bool = Field(
|
||||
default=True,
|
||||
@@ -208,6 +343,11 @@ class Settings(BaseSettings):
|
||||
default=DEFAULT_SUBSCRIPTION_PURCHASE_DESCRIPTION_EN,
|
||||
description="English subscription description shown before purchase/renewal options.",
|
||||
)
|
||||
PAYMENT_REQUEST_TIMEOUT_SECONDS: float = Field(
|
||||
default=20,
|
||||
ge=1,
|
||||
description="Maximum total time for one payment provider API request, in seconds.",
|
||||
)
|
||||
|
||||
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
||||
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
||||
@@ -282,10 +422,35 @@ class Settings(BaseSettings):
|
||||
default=3,
|
||||
description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501
|
||||
)
|
||||
REFERRAL_WELCOME_BONUS_WITHOUT_TELEGRAM_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Allow referral welcome bonus grants for users who have not linked Telegram. "
|
||||
"Disposable email domains are still blocked until Telegram is linked."
|
||||
),
|
||||
)
|
||||
LEGACY_REFS: bool = Field(
|
||||
default=True,
|
||||
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset.", # noqa: E501
|
||||
)
|
||||
MIGRATION_REMNASHOP_REFERRAL_CODE_COMPAT_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Accept referral links imported from snoups/remnashop via legacy_referral_codes."
|
||||
),
|
||||
)
|
||||
MIGRATION_REMNASHOP_PROMO_CODE_COMPAT_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Try exact legacy Remnashop promo codes before uppercase normalization.",
|
||||
)
|
||||
MIGRATION_REMNASHOP_IMPORTED_AT: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Timestamp of the latest Remnashop import run, managed by the import script.",
|
||||
)
|
||||
MIGRATION_REMNASHOP_NOTES: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Operator notes for instances migrated from Remnashop.",
|
||||
)
|
||||
|
||||
APP_RUNTIME_MODE: str = Field(
|
||||
default="production",
|
||||
@@ -328,6 +493,13 @@ class Settings(BaseSettings):
|
||||
TRIAL_DURATION_DAYS: int = Field(default=3)
|
||||
TRIAL_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=5.0)
|
||||
TRIAL_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
TRIAL_WITHOUT_TELEGRAM_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Allow trial activation for users who have not linked Telegram. "
|
||||
"Disposable email domains are still blocked until Telegram is linked."
|
||||
),
|
||||
)
|
||||
TRIAL_SQUAD_UUIDS: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
@@ -373,15 +545,6 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_USE_EMOJI: bool = Field(default=False)
|
||||
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
|
||||
WEBAPP_LOGO_EMOJI_FONT: str = Field(
|
||||
default="system",
|
||||
description=(
|
||||
"Emoji font for logo fallback: system, noto-color, noto-color-animated, "
|
||||
"noto-emoji, twemoji, openmoji, apple, segoe, noto-local"
|
||||
),
|
||||
)
|
||||
WEBAPP_FAVICON_USE_CUSTOM: bool = Field(default=False)
|
||||
WEBAPP_FAVICON_URL: Optional[str] = Field(default=None)
|
||||
WEBAPP_LOGO_FAVICON_URL: Optional[str] = Field(default=None)
|
||||
@@ -440,6 +603,13 @@ class Settings(BaseSettings):
|
||||
SMTP_PASSWORD: Optional[str] = Field(default=None)
|
||||
SMTP_FROM_EMAIL: Optional[str] = Field(default=None)
|
||||
SMTP_FROM_NAME: Optional[str] = Field(default=None)
|
||||
DISPOSABLE_EMAIL_DOMAINS: str = Field(
|
||||
default=DEFAULT_DISPOSABLE_EMAIL_DOMAINS,
|
||||
description=(
|
||||
"Disposable email domains treated as requiring Telegram for trial and "
|
||||
"referral welcome bonus abuse protection. Accepts commas or one domain per line."
|
||||
),
|
||||
)
|
||||
SMTP_STARTTLS: bool = Field(default=True)
|
||||
SMTP_USE_SSL: bool = Field(default=False)
|
||||
EMAIL_CODE_TTL_SECONDS: int = Field(default=10 * 60)
|
||||
@@ -544,9 +714,6 @@ class Settings(BaseSettings):
|
||||
title=self.WEBAPP_TITLE,
|
||||
primary_color=self.WEBAPP_PRIMARY_COLOR,
|
||||
logo_url=self.WEBAPP_LOGO_URL,
|
||||
logo_use_emoji=self.WEBAPP_LOGO_USE_EMOJI,
|
||||
logo_emoji=self.WEBAPP_LOGO_EMOJI,
|
||||
logo_emoji_font=self.WEBAPP_LOGO_EMOJI_FONT,
|
||||
favicon_use_custom=self.WEBAPP_FAVICON_USE_CUSTOM,
|
||||
favicon_url=self.WEBAPP_FAVICON_URL,
|
||||
logo_favicon_url=self.WEBAPP_LOGO_FAVICON_URL,
|
||||
@@ -627,6 +794,16 @@ class Settings(BaseSettings):
|
||||
return trial_squads
|
||||
return self.parsed_user_squad_uuids
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def disposable_email_domains(self) -> List[str]:
|
||||
domains: List[str] = []
|
||||
for domain in _split_csv(self.DISPOSABLE_EMAIL_DOMAINS):
|
||||
normalized = domain.strip().lower().lstrip("@.")
|
||||
if normalized and normalized not in domains:
|
||||
domains.append(normalized)
|
||||
return domains
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_user_external_squad_uuid(self) -> Optional[str]:
|
||||
@@ -788,21 +965,6 @@ class Settings(BaseSettings):
|
||||
def ignore_deprecated_webapp_logo_url_env(cls, _value):
|
||||
return None
|
||||
|
||||
@field_validator("WEBAPP_LOGO_USE_EMOJI", mode="before")
|
||||
@classmethod
|
||||
def ignore_deprecated_webapp_logo_use_emoji_env(cls, _value):
|
||||
return False
|
||||
|
||||
@field_validator("WEBAPP_LOGO_EMOJI", mode="before")
|
||||
@classmethod
|
||||
def ignore_deprecated_webapp_logo_emoji_env(cls, _value):
|
||||
return "🫥"
|
||||
|
||||
@field_validator("WEBAPP_LOGO_EMOJI_FONT", mode="before")
|
||||
@classmethod
|
||||
def ignore_deprecated_webapp_logo_emoji_font_env(cls, _value):
|
||||
return "system"
|
||||
|
||||
@field_validator("WEBAPP_FAVICON_USE_CUSTOM", mode="before")
|
||||
@classmethod
|
||||
def ignore_deprecated_webapp_favicon_use_custom_env(cls, _value):
|
||||
@@ -893,6 +1055,7 @@ class Settings(BaseSettings):
|
||||
"stars",
|
||||
"cryptopay",
|
||||
"heleket",
|
||||
"paykilla",
|
||||
]
|
||||
# Make sure default_order itself includes every registered spec.
|
||||
for sid in spec_ids:
|
||||
@@ -1105,9 +1268,10 @@ class Settings(BaseSettings):
|
||||
TELEMETRY_ENABLED: bool = Field(
|
||||
default=True,
|
||||
description=(
|
||||
"Send an anonymous daily install heartbeat (version, OS, locale, "
|
||||
"user-count range). No personal data. Opt out here, via the web "
|
||||
"admin, or by clearing TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
|
||||
"Send an anonymous daily install heartbeat (version, official/custom "
|
||||
"image provenance, OS, locale, user-count range). No personal data. "
|
||||
"Opt out here, via the web admin, or by clearing "
|
||||
"TELEMETRY_ENDPOINT/TELEMETRY_API_KEY."
|
||||
),
|
||||
)
|
||||
TELEMETRY_ENDPOINT: str = Field(
|
||||
|
||||
@@ -54,6 +54,8 @@ class ThemeTokens(BaseModel):
|
||||
font_logo: Optional[str] = None
|
||||
font_mono: Optional[str] = None
|
||||
home_logo_scale: Optional[int] = None
|
||||
home_logo_scale_desktop: Optional[int] = None
|
||||
home_logo_scale_mobile: Optional[int] = None
|
||||
admin_bg: Optional[str] = None
|
||||
admin_surface: Optional[str] = None
|
||||
admin_surface_2: Optional[str] = None
|
||||
@@ -80,14 +82,14 @@ class ThemeTokens(BaseModel):
|
||||
hex_value = "".join(char * 2 for char in hex_value)
|
||||
return f"#{hex_value}"
|
||||
|
||||
@field_validator("home_logo_scale")
|
||||
@field_validator("home_logo_scale", "home_logo_scale_desktop", "home_logo_scale_mobile")
|
||||
@classmethod
|
||||
def _normalize_home_logo_scale(cls, value: Optional[int]) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
scale = int(value)
|
||||
if scale < 50 or scale > 300:
|
||||
raise ValueError("home_logo_scale must be between 50 and 300 percent")
|
||||
raise ValueError("home logo scale must be between 50 and 300 percent")
|
||||
return scale
|
||||
|
||||
|
||||
@@ -369,6 +371,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
||||
"--success-text" not in style
|
||||
or ".theme-key-light.app-shell" not in style
|
||||
or "Install guide theme surfaces" not in style
|
||||
or "Admin controls: range sliders and sortable rows" not in style
|
||||
or "Admin health config alerts" not in style
|
||||
)
|
||||
if key == "ascii":
|
||||
return (
|
||||
@@ -379,6 +383,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
||||
or "Console-style tables" not in style
|
||||
or "New webapp surfaces: support, purchase info, password login" not in style
|
||||
or "Install guide theme surfaces" not in style
|
||||
or "Admin controls: range sliders and sortable rows" not in style
|
||||
or "Admin health config alerts" not in style
|
||||
)
|
||||
if key != "windows95":
|
||||
return False
|
||||
@@ -402,6 +408,8 @@ def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
|
||||
or "lucide-qr-code" not in style
|
||||
or "New webapp surfaces: support, purchase info, password login" not in style
|
||||
or "Install guide theme surfaces" not in style
|
||||
or "Admin controls: range sliders and sortable rows" not in style
|
||||
or "Admin health config alerts" not in style
|
||||
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
|
||||
)
|
||||
|
||||
@@ -614,6 +622,38 @@ def merge_primary_accent_into_theme_tokens(
|
||||
return base
|
||||
|
||||
|
||||
def effective_webapp_theme_accent(
|
||||
config: WebappThemesConfig,
|
||||
primary_accent: str,
|
||||
*,
|
||||
theme_key: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return the accent color users see for the selected/default Web App theme."""
|
||||
try:
|
||||
fallback = ThemeTokens(accent=primary_accent or "#00fe7a").accent or "#00fe7a"
|
||||
except ValueError:
|
||||
fallback = "#00fe7a"
|
||||
theme: Optional[WebappTheme] = None
|
||||
if theme_key:
|
||||
theme = config.theme_by_key(theme_key)
|
||||
if theme is not None and not theme.enabled:
|
||||
theme = None
|
||||
if theme is None:
|
||||
theme = config.theme_by_key(config.default_theme)
|
||||
if theme is None:
|
||||
enabled = config.enabled_themes()
|
||||
theme = enabled[0] if enabled else None
|
||||
if theme is None:
|
||||
return fallback
|
||||
|
||||
tokens = (
|
||||
merge_primary_accent_into_theme_tokens(theme, fallback)
|
||||
if theme.use_primary_accent
|
||||
else theme.tokens
|
||||
)
|
||||
return tokens.accent or fallback
|
||||
|
||||
|
||||
def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]:
|
||||
tokens = (
|
||||
merge_primary_accent_into_theme_tokens(theme, primary_accent)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import Date, and_, case, cast, func
|
||||
from sqlalchemy import Date, and_, case, cast, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
@@ -51,6 +51,15 @@ async def ensure_payment_with_provider_id(
|
||||
description: str,
|
||||
provider: str,
|
||||
provider_payment_id: str,
|
||||
sale_mode: Optional[str] = None,
|
||||
tariff_key: Optional[str] = None,
|
||||
purchased_gb: Optional[float] = None,
|
||||
purchased_hwid_devices: Optional[int] = None,
|
||||
hwid_valid_from: Optional[Any] = None,
|
||||
hwid_valid_until: Optional[Any] = None,
|
||||
hwid_pricing_period_months: Optional[int] = None,
|
||||
hwid_proration_ratio: Optional[float] = None,
|
||||
hwid_full_price: Optional[float] = None,
|
||||
) -> Payment:
|
||||
"""Idempotently create a payment record for a provider event.
|
||||
|
||||
@@ -72,6 +81,20 @@ async def ensure_payment_with_provider_id(
|
||||
"provider_payment_id": provider_payment_id,
|
||||
"provider": provider,
|
||||
}
|
||||
optional_fields = {
|
||||
"sale_mode": sale_mode,
|
||||
"tariff_key": tariff_key,
|
||||
"purchased_gb": purchased_gb,
|
||||
"purchased_hwid_devices": purchased_hwid_devices,
|
||||
"hwid_valid_from": hwid_valid_from,
|
||||
"hwid_valid_until": hwid_valid_until,
|
||||
"hwid_pricing_period_months": hwid_pricing_period_months,
|
||||
"hwid_proration_ratio": hwid_proration_ratio,
|
||||
"hwid_full_price": hwid_full_price,
|
||||
}
|
||||
payment_payload.update(
|
||||
{field: value for field, value in optional_fields.items() if value is not None}
|
||||
)
|
||||
return await create_payment_record(session, payment_payload)
|
||||
|
||||
|
||||
@@ -93,31 +116,41 @@ async def find_recent_pending_provider_payment(
|
||||
provider: str,
|
||||
pending_status: str,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
sale_mode: Optional[str],
|
||||
months: Optional[int],
|
||||
purchased_gb: Optional[float],
|
||||
purchased_hwid_devices: Optional[int],
|
||||
tariff_key: Optional[str] = None,
|
||||
since_minutes: int = 60,
|
||||
since_minutes: Optional[int] = None,
|
||||
) -> Optional[Payment]:
|
||||
"""Return the most recent pending payment matching the given tariff parameters.
|
||||
|
||||
Used to reuse an existing provider payment link instead of creating a new one
|
||||
on repeated user clicks. Only payments with a populated ``provider_payment_id``
|
||||
are returned — without it, there's no link to reuse.
|
||||
on repeated user clicks. A generic or provider-specific payment id must be
|
||||
populated so the caller can verify the remote payment link.
|
||||
|
||||
Status matching is case-insensitive and also accepts the generic ``pending``
|
||||
alias so legacy rows (e.g. Platega ``PENDING`` or YooKassa ``pending``) stay
|
||||
reusable after provider APIs overwrite the internal pending status.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes))
|
||||
|
||||
conditions = [
|
||||
Payment.user_id == user_id,
|
||||
Payment.provider == provider,
|
||||
Payment.status == pending_status,
|
||||
Payment.provider_payment_id.isnot(None),
|
||||
Payment.created_at >= cutoff,
|
||||
func.lower(Payment.status).in_(tuple({str(pending_status).lower(), "pending"})),
|
||||
or_(
|
||||
Payment.provider_payment_id.isnot(None),
|
||||
Payment.yookassa_payment_id.isnot(None),
|
||||
),
|
||||
func.abs(Payment.amount - float(amount)) < 0.01,
|
||||
]
|
||||
if since_minutes is not None:
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max(1, since_minutes))
|
||||
conditions.append(Payment.created_at >= cutoff)
|
||||
if currency is not None:
|
||||
conditions.append(func.upper(Payment.currency) == str(currency).strip().upper())
|
||||
if sale_mode is not None:
|
||||
conditions.append(Payment.sale_mode == sale_mode)
|
||||
if tariff_key is not None:
|
||||
@@ -215,12 +248,18 @@ async def count_user_succeeded_payments(
|
||||
|
||||
|
||||
async def update_provider_payment_and_status(
|
||||
session: AsyncSession, payment_db_id: int, provider_payment_id: str, new_status: str
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
provider_payment_id: str,
|
||||
new_status: str,
|
||||
provider_payment_url: Optional[str] = None,
|
||||
) -> Optional[Payment]:
|
||||
payment = await get_payment_by_db_id(session, payment_db_id)
|
||||
if payment:
|
||||
payment.status = new_status
|
||||
payment.provider_payment_id = provider_payment_id
|
||||
if provider_payment_url:
|
||||
payment.provider_payment_url = provider_payment_url
|
||||
payment.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(payment)
|
||||
|
||||
@@ -22,24 +22,46 @@ async def get_promo_code_by_id(session: AsyncSession, promo_code_id: int) -> Opt
|
||||
return await session.get(PromoCode, promo_code_id)
|
||||
|
||||
|
||||
async def get_promo_code_by_code(session: AsyncSession, code_str: str) -> Optional[PromoCode]:
|
||||
def _promo_lookup_candidates(code_str: str, *, preserve_case: bool) -> List[str]:
|
||||
code = str(code_str or "").strip()
|
||||
if not code:
|
||||
return []
|
||||
candidates = [code] if preserve_case else []
|
||||
upper_code = code.upper()
|
||||
if upper_code not in candidates:
|
||||
candidates.append(upper_code)
|
||||
return candidates
|
||||
|
||||
|
||||
async def get_promo_code_by_code(
|
||||
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||
) -> Optional[PromoCode]:
|
||||
"""Get promo code by code string (regardless of active status)"""
|
||||
stmt = select(PromoCode).where(PromoCode.code == code_str.upper())
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||
stmt = select(PromoCode).where(PromoCode.code == candidate)
|
||||
result = await session.execute(stmt)
|
||||
promo = result.scalar_one_or_none()
|
||||
if promo:
|
||||
return promo
|
||||
return None
|
||||
|
||||
|
||||
async def get_active_promo_code_by_code_str(
|
||||
session: AsyncSession, code_str: str
|
||||
session: AsyncSession, code_str: str, *, preserve_case: bool = False
|
||||
) -> Optional[PromoCode]:
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == code_str.upper(),
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > datetime.now(timezone.utc)),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
now = datetime.now(timezone.utc)
|
||||
for candidate in _promo_lookup_candidates(code_str, preserve_case=preserve_case):
|
||||
stmt = select(PromoCode).where(
|
||||
PromoCode.code == candidate,
|
||||
PromoCode.is_active == True,
|
||||
PromoCode.current_activations < PromoCode.max_activations,
|
||||
or_(PromoCode.valid_until == None, PromoCode.valid_until > now),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
promo = result.scalar_one_or_none()
|
||||
if promo:
|
||||
return promo
|
||||
return None
|
||||
|
||||
|
||||
async def get_all_active_promo_codes(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import inspect
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import and_, delete, func, or_, select, update
|
||||
@@ -189,6 +189,63 @@ async def expire_hwid_device_purchases(
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def _normalize_aware_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value
|
||||
|
||||
|
||||
async def extend_hwid_device_purchases_for_subscription_bonus(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
subscription_id: int,
|
||||
at: Optional[datetime] = None,
|
||||
subscription_end_before: Optional[datetime] = None,
|
||||
delta: timedelta,
|
||||
) -> int:
|
||||
if delta.total_seconds() <= 0:
|
||||
return 0
|
||||
at = _normalize_aware_utc(at or datetime.now(timezone.utc))
|
||||
end_before = _normalize_aware_utc(subscription_end_before) if subscription_end_before else None
|
||||
|
||||
target_records: List[HwidDevicePurchase] = []
|
||||
if end_before:
|
||||
tail_result = await session.execute(
|
||||
select(HwidDevicePurchase).where(
|
||||
and_(
|
||||
HwidDevicePurchase.subscription_id == subscription_id,
|
||||
HwidDevicePurchase.purchased_devices > 0,
|
||||
HwidDevicePurchase.valid_until.is_not(None),
|
||||
HwidDevicePurchase.valid_until >= end_before,
|
||||
HwidDevicePurchase.valid_until > at,
|
||||
or_(
|
||||
HwidDevicePurchase.valid_from.is_(None),
|
||||
HwidDevicePurchase.valid_from < end_before,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
target_records = list(tail_result.scalars().all())
|
||||
|
||||
if not target_records:
|
||||
active_result = await session.execute(
|
||||
select(HwidDevicePurchase).where(
|
||||
and_(
|
||||
*_hwid_active_conditions(subscription_id, at),
|
||||
HwidDevicePurchase.valid_until.is_not(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
target_records = list(active_result.scalars().all())
|
||||
|
||||
for record in target_records:
|
||||
if record.valid_until is not None:
|
||||
record.valid_until = _normalize_aware_utc(record.valid_until) + delta
|
||||
if target_records:
|
||||
await session.flush()
|
||||
return len(target_records)
|
||||
|
||||
|
||||
async def create_tariff_change(
|
||||
session: AsyncSession,
|
||||
change_data: Dict[str, Any],
|
||||
|
||||
+189
-6
@@ -4,7 +4,7 @@ import string
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, case, delete, desc, func, or_, update
|
||||
from sqlalchemy import String, and_, case, cast, delete, desc, func, or_, update
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
@@ -14,6 +14,8 @@ from ..models import (
|
||||
AdAttribution,
|
||||
EmailVerificationCode,
|
||||
HwidDevicePurchase,
|
||||
LegacyImportMapping,
|
||||
LegacyReferralCode,
|
||||
MessageLog,
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
@@ -76,7 +78,7 @@ async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||
Returns the existing or newly generated code.
|
||||
"""
|
||||
if user.referral_code:
|
||||
normalized = user.referral_code.strip().upper()
|
||||
normalized = user.referral_code.strip()
|
||||
if normalized != user.referral_code:
|
||||
user.referral_code = normalized
|
||||
await session.flush()
|
||||
@@ -210,7 +212,7 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
||||
if not user_data.get("referral_code"):
|
||||
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||
else:
|
||||
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
||||
user_data["referral_code"] = user_data["referral_code"].strip()
|
||||
|
||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||
stmt = (
|
||||
@@ -567,6 +569,19 @@ async def merge_users(
|
||||
await session.execute(
|
||||
update(model).where(model.user_id == source_user_id).values(user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(LegacyReferralCode)
|
||||
.where(LegacyReferralCode.user_id == source_user_id)
|
||||
.values(user_id=target_user_id)
|
||||
)
|
||||
await session.execute(
|
||||
update(LegacyImportMapping)
|
||||
.where(
|
||||
LegacyImportMapping.target_table == "users",
|
||||
LegacyImportMapping.target_id == str(source_user_id),
|
||||
)
|
||||
.values(target_id=str(target_user_id))
|
||||
)
|
||||
|
||||
await session.execute(
|
||||
update(MessageLog)
|
||||
@@ -590,13 +605,60 @@ async def merge_users(
|
||||
return target
|
||||
|
||||
|
||||
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||
normalized = referral_code.strip().upper()
|
||||
async def get_user_by_referral_code(
|
||||
session: AsyncSession,
|
||||
referral_code: str,
|
||||
*,
|
||||
include_legacy: bool = False,
|
||||
) -> Optional[User]:
|
||||
normalized = referral_code.strip()
|
||||
if not normalized:
|
||||
return None
|
||||
|
||||
stmt = select(User).where(User.referral_code == normalized)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
upper_normalized = normalized.upper()
|
||||
if upper_normalized != normalized:
|
||||
stmt = select(User).where(User.referral_code == upper_normalized)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if not include_legacy:
|
||||
return None
|
||||
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||
.where(LegacyReferralCode.code == normalized, LegacyReferralCode.is_active == True)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
if upper_normalized != normalized:
|
||||
stmt = (
|
||||
select(User)
|
||||
.join(LegacyReferralCode, LegacyReferralCode.user_id == User.user_id)
|
||||
.where(
|
||||
LegacyReferralCode.code == upper_normalized,
|
||||
LegacyReferralCode.is_active == True,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def update_user(
|
||||
@@ -653,6 +715,12 @@ async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[i
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_all_active_users_for_broadcast(session: AsyncSession) -> int:
|
||||
stmt = select(func.count(User.user_id)).where(User.is_banned == False)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
|
||||
stmt = select(User).where(User.panel_user_uuid.is_not(None))
|
||||
result = await session.execute(stmt)
|
||||
@@ -828,6 +896,27 @@ async def get_user_ids_with_active_subscription(session: AsyncSession) -> List[i
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_users_with_active_subscription_for_broadcast(session: AsyncSession) -> int:
|
||||
"""Count non-banned users who have any active subscription."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
stmt = (
|
||||
select(func.count(func.distinct(Subscription.user_id)))
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.is_banned == False,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > now,
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def get_user_ids_without_active_subscription(session: AsyncSession) -> List[int]:
|
||||
"""Return non-banned user IDs who do NOT have any active subscription."""
|
||||
from datetime import datetime, timezone
|
||||
@@ -857,6 +946,61 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_users_without_active_subscription_for_broadcast(session: AsyncSession) -> int:
|
||||
"""Count non-banned users who do NOT have any active subscription."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
stmt = select(func.count(User.user_id)).where(
|
||||
User.is_banned == False,
|
||||
~_active_subscription_exists_for_user(now),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def get_user_ids_without_any_subscription(session: AsyncSession) -> List[int]:
|
||||
"""Return non-banned user IDs who never had any subscription or trial.
|
||||
|
||||
These are users who registered but have no ``Subscription`` rows at all —
|
||||
no active, no expired and no trial history. In other words, accounts that
|
||||
signed up and never did anything.
|
||||
"""
|
||||
any_sub = aliased(Subscription)
|
||||
|
||||
stmt = (
|
||||
select(User.user_id)
|
||||
.outerjoin(any_sub, any_sub.user_id == User.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.is_banned == False,
|
||||
any_sub.user_id.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_users_without_any_subscription_for_broadcast(session: AsyncSession) -> int:
|
||||
"""Count non-banned users who never had any subscription or trial."""
|
||||
any_sub = aliased(Subscription)
|
||||
|
||||
stmt = (
|
||||
select(func.count(User.user_id))
|
||||
.outerjoin(any_sub, any_sub.user_id == User.user_id)
|
||||
.where(
|
||||
and_(
|
||||
User.is_banned == False,
|
||||
any_sub.user_id.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
def _expired_subscription_exists_for_user(now: datetime):
|
||||
expired_subs = aliased(Subscription)
|
||||
normalized_status = func.lower(func.coalesce(expired_subs.status_from_panel, ""))
|
||||
@@ -903,6 +1047,20 @@ async def count_users_with_expired_subscription(session: AsyncSession) -> int:
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def count_users_with_expired_subscription_for_broadcast(session: AsyncSession) -> int:
|
||||
"""Count non-banned users with an expired subscription and no active one."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
stmt = select(func.count(User.user_id)).where(
|
||||
User.is_banned == False,
|
||||
_expired_subscription_exists_for_user(now),
|
||||
~_active_subscription_exists_for_user(now),
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
|
||||
async def get_user_ids_with_expired_subscription(session: AsyncSession) -> List[int]:
|
||||
"""Return non-banned user IDs with an expired subscription and no active one."""
|
||||
from datetime import datetime, timezone
|
||||
@@ -997,6 +1155,31 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||
await session.execute(delete(UserTelegramAvatar).where(UserTelegramAvatar.user_id == user_id))
|
||||
await session.execute(delete(LegacyReferralCode).where(LegacyReferralCode.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(LegacyImportMapping).where(
|
||||
or_(
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "users",
|
||||
LegacyImportMapping.target_id == str(user_id),
|
||||
),
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "subscriptions",
|
||||
LegacyImportMapping.target_id.in_(
|
||||
select(cast(Subscription.subscription_id, String)).where(
|
||||
Subscription.user_id == user_id
|
||||
)
|
||||
),
|
||||
),
|
||||
and_(
|
||||
LegacyImportMapping.target_table == "payments",
|
||||
LegacyImportMapping.target_id.in_(
|
||||
select(cast(Payment.payment_id, String)).where(Payment.user_id == user_id)
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(delete(Subscription).where(Subscription.user_id == user_id))
|
||||
|
||||
|
||||
@@ -1070,6 +1070,98 @@ def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -
|
||||
)
|
||||
|
||||
|
||||
def _migration_0034_add_legacy_import_compatibility(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
table_names = set(inspector.get_table_names())
|
||||
|
||||
if "users" in table_names:
|
||||
columns = {col["name"]: col for col in inspector.get_columns("users")}
|
||||
referral_column = columns.get("referral_code")
|
||||
length = getattr(referral_column.get("type"), "length", None) if referral_column else None
|
||||
if referral_column and (length is None or int(length) < 64):
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ALTER COLUMN referral_code TYPE VARCHAR(64)")
|
||||
)
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS legacy_referral_codes (
|
||||
legacy_code_id SERIAL PRIMARY KEY,
|
||||
source VARCHAR(64) NOT NULL DEFAULT 'remnashop',
|
||||
code VARCHAR(128) NOT NULL,
|
||||
user_id BIGINT NOT NULL REFERENCES users(user_id),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_legacy_referral_source_code UNIQUE (source, code)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
for stmt in [
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_source "
|
||||
"ON legacy_referral_codes (source)"
|
||||
),
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_code ON legacy_referral_codes (code)",
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_user_id "
|
||||
"ON legacy_referral_codes (user_id)"
|
||||
),
|
||||
(
|
||||
"CREATE INDEX IF NOT EXISTS ix_legacy_referral_codes_is_active "
|
||||
"ON legacy_referral_codes (is_active)"
|
||||
),
|
||||
]:
|
||||
connection.execute(text(stmt))
|
||||
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS legacy_import_mappings (
|
||||
source VARCHAR(64) NOT NULL,
|
||||
entity_type VARCHAR(64) NOT NULL,
|
||||
source_id VARCHAR(128) NOT NULL,
|
||||
target_table VARCHAR(128) NOT NULL,
|
||||
target_id VARCHAR(128) NOT NULL,
|
||||
metadata_json TEXT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NULL,
|
||||
PRIMARY KEY (source, entity_type, source_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS ix_legacy_import_mappings_target
|
||||
ON legacy_import_mappings (target_table, target_id)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0035_add_subscription_promo_expiry_flag(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("subscriptions")}
|
||||
if "suppress_early_expiry_notifications" not in columns:
|
||||
connection.execute(
|
||||
text(
|
||||
"ALTER TABLE subscriptions ADD COLUMN suppress_early_expiry_notifications "
|
||||
"BOOLEAN NOT NULL DEFAULT FALSE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0036_add_provider_payment_url(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
|
||||
if "provider_payment_url" not in columns:
|
||||
connection.execute(text("ALTER TABLE payments ADD COLUMN provider_payment_url VARCHAR"))
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
@@ -1247,6 +1339,21 @@ MIGRATIONS: List[Migration] = [
|
||||
description="Track admin resets of per-user trial eligibility without deleting history",
|
||||
upgrade=_migration_0033_add_trial_eligibility_reset_marker,
|
||||
),
|
||||
Migration(
|
||||
id="0034_add_legacy_import_compatibility",
|
||||
description="Store legacy import mappings and referral codes for source-bot migrations",
|
||||
upgrade=_migration_0034_add_legacy_import_compatibility,
|
||||
),
|
||||
Migration(
|
||||
id="0035_add_subscription_promo_expiry_flag",
|
||||
description="Suppress multi-day expiry reminders for trial and bonus subscriptions",
|
||||
upgrade=_migration_0035_add_subscription_promo_expiry_flag,
|
||||
),
|
||||
Migration(
|
||||
id="0036_add_provider_payment_url",
|
||||
description="Persist provider payment links for reusable pending payments",
|
||||
upgrade=_migration_0036_add_provider_payment_url,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+37
-1
@@ -43,7 +43,7 @@ class User(Base):
|
||||
registration_date = Column(DateTime(timezone=True), server_default=func.now())
|
||||
is_banned = Column(Boolean, default=False)
|
||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
||||
referral_code = Column(String(64), nullable=True, unique=True, index=True)
|
||||
referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True)
|
||||
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
|
||||
lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True)
|
||||
@@ -122,6 +122,12 @@ class Subscription(Base):
|
||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||
provider = Column(String, nullable=True)
|
||||
skip_notifications = Column(Boolean, default=False)
|
||||
# Trial and registration/referral-bonus subscriptions are only a few days
|
||||
# long, so the multi-day "ending soon" reminders would fire almost as soon
|
||||
# as they are granted. While this is set the worker keeps only the
|
||||
# hours-before reminder plus the expiry/after-expiry notices; a real payment
|
||||
# clears it so the full reminder spectrum resumes.
|
||||
suppress_early_expiry_notifications = Column(Boolean, nullable=False, default=False)
|
||||
auto_renew_enabled = Column(Boolean, default=True, index=True)
|
||||
tariff_key = Column(String, nullable=True, index=True)
|
||||
tier_baseline_bytes = Column(BigInteger, nullable=True)
|
||||
@@ -197,6 +203,7 @@ class Payment(Base):
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
yookassa_payment_id = Column(String, unique=True, index=True, nullable=True)
|
||||
provider_payment_id = Column(String, unique=True, nullable=True)
|
||||
provider_payment_url = Column(String, nullable=True)
|
||||
provider = Column(String, nullable=False, default="yookassa", index=True)
|
||||
idempotence_key = Column(String, unique=True, nullable=True)
|
||||
amount = Column(Float, nullable=False)
|
||||
@@ -396,6 +403,35 @@ class PromoCodeActivation(Base):
|
||||
)
|
||||
|
||||
|
||||
class LegacyReferralCode(Base):
|
||||
__tablename__ = "legacy_referral_codes"
|
||||
|
||||
legacy_code_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
source = Column(String(64), nullable=False, default="remnashop", index=True)
|
||||
code = Column(String(128), nullable=False, index=True)
|
||||
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
user = relationship("User")
|
||||
|
||||
__table_args__ = (UniqueConstraint("source", "code", name="uq_legacy_referral_source_code"),)
|
||||
|
||||
|
||||
class LegacyImportMapping(Base):
|
||||
__tablename__ = "legacy_import_mappings"
|
||||
|
||||
source = Column(String(64), primary_key=True)
|
||||
entity_type = Column(String(64), primary_key=True)
|
||||
source_id = Column(String(128), primary_key=True)
|
||||
target_table = Column(String(128), nullable=False)
|
||||
target_id = Column(String(128), nullable=False)
|
||||
metadata_json = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True)
|
||||
|
||||
|
||||
class MessageLog(Base):
|
||||
__tablename__ = "message_logs"
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
aiogram==3.28.2
|
||||
# Upstream aiogram 3.28.2 commit with aiohttp<3.15 support.
|
||||
aiogram @ https://github.com/aiogram/aiogram/archive/8c2f78d51f10f3e2189d3828ba80ca3c1f094b5d.zip
|
||||
python-dotenv==1.2.2
|
||||
aiohttp==3.13.5
|
||||
aiohttp>=3.14.0,<4
|
||||
pydantic==2.13.4
|
||||
yookassa==3.10.1
|
||||
httpx>=0.27.0
|
||||
@@ -9,6 +10,6 @@ email-validator==2.3.0
|
||||
sqlalchemy[asyncio]==2.0.49
|
||||
asyncpg==0.31.0
|
||||
aiocryptopay==0.4.8
|
||||
PyJWT[crypto]==2.12.1
|
||||
PyJWT[crypto]==2.13.0
|
||||
Pillow==12.2.0
|
||||
redis==6.4.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Operational one-shot scripts shipped with the backend image."""
|
||||
File diff suppressed because it is too large
Load Diff
+46
-69
@@ -1,11 +1,12 @@
|
||||
# Resolve the application version from .git at build time and emit a tiny
|
||||
# .build-version file. The .git tree is consumed in this throwaway stage and
|
||||
# never copied into the runtime image; only the tag + commit version string
|
||||
# ships. Non-main builds include the branch name so they are visibly distinct
|
||||
# from release builds. This matches the runtime fallback chain in _resolve_app_version
|
||||
# (REMNAWAVE_MINISHOP_VERSION env > .build-version file > live git >
|
||||
# "dev+unknown") so the admin sidebar always shows a tag / sha even though the
|
||||
# runtime images have no git tooling and no .git tree.
|
||||
# never copied into the runtime image; only the tag + commit version string and
|
||||
# a low-cardinality build provenance marker ship. Non-main builds include the
|
||||
# branch name so they are visibly distinct from release builds. This matches the
|
||||
# runtime fallback chain in _resolve_app_version (REMNAWAVE_MINISHOP_VERSION env
|
||||
# > .build-version file > live git > "dev+unknown") so the admin sidebar always
|
||||
# shows a tag / sha even though the runtime images have no git tooling and no
|
||||
# .git tree.
|
||||
FROM alpine:3.20 AS version-builder
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /repo
|
||||
@@ -14,6 +15,7 @@ ARG GIT_BRANCH=""
|
||||
ARG BRANCH_NAME=""
|
||||
ARG GITHUB_REF_NAME=""
|
||||
ARG CI_COMMIT_REF_NAME=""
|
||||
ARG REMNAWAVE_MINISHOP_BUILD_PROVENANCE="custom"
|
||||
COPY .git ./.git
|
||||
RUN set -eu; \
|
||||
git config --global --add safe.directory /repo; \
|
||||
@@ -40,9 +42,17 @@ RUN set -eu; \
|
||||
else \
|
||||
version="dev${branch_suffix}+unknown"; \
|
||||
fi; \
|
||||
provenance=$(printf '%s' "$REMNAWAVE_MINISHOP_BUILD_PROVENANCE" | tr '[:upper:]' '[:lower:]'); \
|
||||
case "$provenance" in \
|
||||
official|custom|unknown) ;; \
|
||||
true|1|yes|upstream|release) provenance="official" ;; \
|
||||
false|0|no|fork|modified|local|"") provenance="custom" ;; \
|
||||
*) provenance="custom" ;; \
|
||||
esac; \
|
||||
printf '%s' "$version" > /build-version; \
|
||||
printf '%s' "${tag:-unknown}" > /build-tag; \
|
||||
printf '%s' "${sha:-unknown}" > /build-commit
|
||||
printf '%s' "${sha:-unknown}" > /build-commit; \
|
||||
printf '%s' "$provenance" > /build-provenance
|
||||
|
||||
|
||||
FROM python:3.12-slim AS python-base
|
||||
@@ -82,12 +92,40 @@ RUN mkdir -p /app/logs /app/data \
|
||||
COPY --from=version-builder /build-version /app/.build-version
|
||||
COPY --from=version-builder /build-tag /app/.build-tag
|
||||
COPY --from=version-builder /build-commit /app/.build-commit
|
||||
COPY --from=version-builder /build-provenance /app/.build-provenance
|
||||
|
||||
USER appuser
|
||||
|
||||
|
||||
FROM node:22-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
COPY frontend ./
|
||||
RUN mkdir -p ../backend/bot/app/web/templates
|
||||
|
||||
RUN npm run build:webapp
|
||||
COPY backend/bot/app/web/templates/subscription_webapp.html ../backend/bot/app/web/templates/subscription_webapp.html
|
||||
RUN node ./scripts/prepare_nginx_assets.mjs --out ../frontend-nginx-dist
|
||||
|
||||
|
||||
FROM python-base AS backend
|
||||
|
||||
# The Mini App shell is rendered by the backend, which rewrites the stylesheet
|
||||
# and script tags to the content-hashed asset names (subscription_webapp.<hash>.css).
|
||||
# Those hashed files are build artifacts and gitignored, so a clean checkout has
|
||||
# none of them: without this copy the asset resolver falls back to the bare
|
||||
# /subscription_webapp.css URL. That URL never changes between deploys and is
|
||||
# served no-store, which iOS WebViews (WKWebView) cache aggressively and refuse to
|
||||
# revalidate -> stale CSS and a broken-looking Mini App on iOS only. Pull the
|
||||
# freshly built assets (the same hashes the nginx image serves) into the image so
|
||||
# the shell emits immutable, cache-busting URLs.
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/ ./backend/bot/app/web/templates/
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
||||
org.opencontainers.image.title="remnawave-minishop-backend" \
|
||||
org.opencontainers.image.description="Remnawave Minishop backend (Telegram bot API, web app, webhooks)." \
|
||||
@@ -107,20 +145,6 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
|
||||
CMD ["python", "backend/main_worker.py"]
|
||||
|
||||
|
||||
FROM node:22-slim AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
COPY frontend/package.json frontend/package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
|
||||
COPY frontend ./
|
||||
RUN mkdir -p ../backend/bot/app/web/templates
|
||||
|
||||
RUN npm run build:webapp
|
||||
|
||||
|
||||
FROM nginx:1.27-alpine AS frontend
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minishop" \
|
||||
@@ -131,57 +155,10 @@ LABEL org.opencontainers.image.source="https://github.com/3252a8/remnawave-minis
|
||||
COPY deploy/docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY deploy/docker/frontend/00-startup-banner.sh /docker-entrypoint.d/00-startup-banner.sh
|
||||
COPY deploy/docker/frontend/robots.txt /usr/share/nginx/html/robots.txt
|
||||
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css.gz /usr/share/nginx/html/
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.css /usr/share/nginx/html/subscription_webapp_admin.css
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css /usr/share/nginx/html/
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.*.css.gz /usr/share/nginx/html/
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js.gz /usr/share/nginx/html/
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.js /usr/share/nginx/html/subscription_webapp_admin.js
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js /usr/share/nginx/html/
|
||||
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp_admin.min.*.js.gz /usr/share/nginx/html/
|
||||
|
||||
RUN set -eu; \
|
||||
find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \
|
||||
HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \
|
||||
JS_NAME="${HASHED:-subscription_webapp.js}"; \
|
||||
CSS_NAME="subscription_webapp.css"; \
|
||||
ADMIN_HASHED=$(ls /usr/share/nginx/html/subscription_webapp_admin.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \
|
||||
ADMIN_CSS_NAME="subscription_webapp_admin.css"; \
|
||||
for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \
|
||||
name="$(basename "$candidate")"; \
|
||||
case "$name" in \
|
||||
subscription_webapp.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) CSS_NAME="$name" ;; \
|
||||
esac; \
|
||||
done; \
|
||||
for candidate in /usr/share/nginx/html/subscription_webapp_admin.*.css; do \
|
||||
name="$(basename "$candidate")"; \
|
||||
case "$name" in \
|
||||
subscription_webapp_admin.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) ADMIN_CSS_NAME="$name" ;; \
|
||||
esac; \
|
||||
done; \
|
||||
if [ -n "$ADMIN_HASHED" ]; then \
|
||||
cp "/usr/share/nginx/html/${ADMIN_HASHED}" /usr/share/nginx/html/subscription_webapp_admin.js; \
|
||||
if [ -f "/usr/share/nginx/html/${ADMIN_HASHED}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_HASHED}.gz" /usr/share/nginx/html/subscription_webapp_admin.js.gz; fi; \
|
||||
fi; \
|
||||
if [ "$ADMIN_CSS_NAME" != "subscription_webapp_admin.css" ]; then \
|
||||
cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}" /usr/share/nginx/html/subscription_webapp_admin.css; \
|
||||
if [ -f "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" ]; then cp "/usr/share/nginx/html/${ADMIN_CSS_NAME}.gz" /usr/share/nginx/html/subscription_webapp_admin.css.gz; fi; \
|
||||
fi; \
|
||||
sed -i \
|
||||
-e '/WEBAPP_I18N_SCRIPT/d' \
|
||||
-e '/WEBAPP_CONFIG_SCRIPT/d' \
|
||||
-e "s|href=\"/subscription_webapp.css\"|href=\"/${CSS_NAME}\"|" \
|
||||
-e "/WEBAPP_JS_SCRIPT/c\\ <script src=\"/${JS_NAME}\" type=\"module\"></script>" \
|
||||
-e '/WEBAPP_DEV_MOCK_START/d' \
|
||||
-e '/WEBAPP_DEV_MOCK_END/d' \
|
||||
-e '/subscription_webapp.js" defer/d' \
|
||||
/usr/share/nginx/html/index.html; \
|
||||
chmod +x /docker-entrypoint.d/00-startup-banner.sh
|
||||
COPY --from=frontend-builder /app/frontend-nginx-dist/ /usr/share/nginx/html/
|
||||
COPY --from=version-builder /build-tag /build-tag
|
||||
COPY --from=version-builder /build-commit /build-commit
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
app.example.com {
|
||||
encode zstd gzip
|
||||
|
||||
# Caddy sets X-Forwarded-For/Host/Proto for reverse_proxy by default.
|
||||
# The backend uses X-Forwarded-For for payment provider IP allowlists.
|
||||
reverse_proxy backend:8080
|
||||
}
|
||||
|
||||
web.example.com {
|
||||
encode zstd gzip
|
||||
|
||||
# Keep forwarded headers for Web App URL generation and request logging.
|
||||
reverse_proxy frontend:80
|
||||
}
|
||||
|
||||
@@ -84,7 +84,16 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-emoji|webapp-theme-css|webapp-theme-assets)/ {
|
||||
location ~ ^/(webapp-logo|webapp-uploaded-logo|webapp-favicon|webapp-theme-css|webapp-theme-assets)/ {
|
||||
proxy_pass http://backend:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ "^/(?:$|login/password$|home$|install$|trial$|s/[a-f0-9]{32}$|invite$|devices$|settings$|support(?:/[0-9]+)?$|admin(?:/.*)?$)" {
|
||||
proxy_pass http://backend:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
@@ -102,7 +111,9 @@ server {
|
||||
|
||||
location ~* ^/subscription_webapp(_admin)?\.(css|js)$ {
|
||||
expires off;
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri =404;
|
||||
}
|
||||
@@ -115,7 +126,9 @@ server {
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-cache";
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
add_header X-Robots-Tag "noindex, nofollow, noarchive";
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ server {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
# Required for payment provider IP allowlists in webhook handlers.
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
|
||||
Эта папка хранит только рабочие compose-примеры и конфиги. Подробное описание не дублируется здесь, чтобы сайт документации и навигация из README использовали один источник.
|
||||
|
||||
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml`. Для кастомных тем создайте `data/themes`.
|
||||
Файлы приложения (`/app/data`: тарифы, темы, логотипы) монтируются из папки `data` рядом с выбранным `docker-compose.yml` в `migrate`, `backend` и `worker`. Для кастомных тем создайте `data/themes`; для ручного каталога тарифов используйте `data/tariffs.json`.
|
||||
|
||||
| Папка | Документация |
|
||||
| --- | --- |
|
||||
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
||||
| `nginx` | [Развертывание с Nginx](../../docs/getting-started/deployment.md#nginx) |
|
||||
| `newt` | [Развертывание через Pangolin / Newt](../../docs/getting-started/deployment.md#pangolin--newt) |
|
||||
| `no-proxy` | [Запуск без обратного прокси](../../docs/getting-started/deployment.md#без-обратного-прокси) |
|
||||
| Папка | Документация |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `caddy` | [Развертывание с Caddy](../../docs/getting-started/deployment.md#caddy-рекомендуемый-вариант) |
|
||||
| `nginx` | [Развертывание с Nginx](../../docs/getting-started/deployment.md#nginx) |
|
||||
| `newt` | [Развертывание через Pangolin / Newt](../../docs/getting-started/deployment.md#pangolin--newt) |
|
||||
| `no-proxy` | [Запуск без обратного прокси](../../docs/getting-started/deployment.md#без-обратного-прокси) |
|
||||
| `mail` | [Развертывание локального SMTP-сервера](../../docs/features/email-login.md#настройка-локального-smtp-сервера) |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user