Compare commits

...

46 Commits

Author SHA1 Message Date
c0mrade 0879b8b218 Merge pull request #2873 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.46.0
2026-04-13 19:47:36 +03:00
github-actions[bot] 1d91382b8e chore(main): release 3.46.0 2026-04-13 16:37:54 +00:00
c0mrade 3768b18a39 Merge pull request #2872 from BEDOLAGA-DEV/dev
Bugfixes: campaign, tickets, NaloGO, devices, broadcasts, menu editor
2026-04-13 19:37:16 +03:00
c0mrade 1eeeb39779 fix: exclude users with active subscriptions from expired broadcast
In multi-subscription mode, a user with an expired trial AND an active
paid subscription was incorrectly included in expired broadcast targets.

Fixed in 3 places:
- get_target_users_count (SQL): added NOT EXISTS active sub subquery
- get_target_users 'expired' (Python): skip if has_active
- get_target_users 'expired_subscribers' (Python): same check
2026-04-13 19:21:52 +03:00
c0mrade 570af82dfd fix: raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor
MAX_BUTTONS_PER_ROW was 3, causing Pydantic 422 when adding multiple
custom buttons to a row. Telegram allows up to 8 buttons per row.

Also added tg:// to URL_PATTERN so admins can use Telegram deep links
(tg://resolve, tg://user, etc.) in custom menu buttons. webapp mode
still requires https:// as enforced by existing validation.
2026-04-13 17:07:19 +03:00
c0mrade bc3893b934 fix: use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons
Admin user device editor had hardcoded limit of 10 in text, validation,
and inline buttons. Now uses settings.MAX_DEVICES_LIMIT dynamically,
generating buttons 1..N in rows of 4. Falls back to text-only input
if limit exceeds Telegram's 100-button cap.
2026-04-13 14:42:16 +03:00
c0mrade 16d91638bc fix: enforce max_attempts limit in NaloGO receipt queue
The _max_attempts property existed but was never checked as a limit.
Receipts were retried indefinitely (55+ attempts observed in logs).
Now receipts exceeding max_attempts are removed from the queue.
2026-04-13 14:42:06 +03:00
c0mrade eb18b3a0f9 fix: handle TelegramBadRequest when deleting old ticket notifications
Messages older than 48 hours cannot be deleted via Telegram API.
Now falls back to editing the message text instead of crashing.
2026-04-13 14:41:59 +03:00
c0mrade a8e2b62f4b feat: save campaign_slug during standalone email registration
Campaign slug was lost during email registration flow — it was only
sent at verification time from localStorage, which is empty if the
user opens the verification link in a different browser/webview.

Now campaign_slug is accepted in the registration request, saved to
user.pending_campaign_slug, and used as fallback during email
verification. Also processed immediately for auto-verified test emails.
2026-04-13 14:41:49 +03:00
c0mrade fb8d2b3ee4 fix: upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens 2026-04-10 18:08:27 +03:00
c0mrade 2321667ecb fix: add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales 2026-04-10 17:58:04 +03:00
c0mrade 113304b212 style: remove unused import (ruff fix) 2026-04-10 16:47:41 +03:00
c0mrade 0300044b00 feat: add category field to broadcast API schemas and routes 2026-04-10 16:10:13 +03:00
c0mrade 931abfe7a5 feat: add broadcast category (system/news/promo) + filter recipients by user prefs 2026-04-10 16:05:43 +03:00
c0mrade 1d96f80f60 feat: add traffic % warning check using user's threshold preference 2026-04-10 15:59:32 +03:00
c0mrade 4e50419171 feat: implement low balance alert + respect user notification preferences
- Add _check_low_balance_alerts to monitoring service
- Notify users with autopay when balance drops below their threshold
- Uses notification_prefs helper for per-user settings
2026-04-10 15:43:32 +03:00
c0mrade 7208a52c94 feat: respect user traffic_warning notification preference in webhook handler 2026-04-10 15:37:33 +03:00
c0mrade 63fdfe4a42 feat: respect user subscription_expiry notification preferences 2026-04-10 15:37:00 +03:00
c0mrade e0e2edf816 feat: add user notification preferences helper utility 2026-04-10 15:35:30 +03:00
c0mrade 522a8779d6 style: fix ruff format for all sync-related changes 2026-04-10 15:13:59 +03:00
c0mrade be32010d63 fix: trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text 2026-04-10 15:04:38 +03:00
c0mrade 7e920fa30f fix: add retry queue to all remaining RemnaWave error handlers 2026-04-10 14:22:10 +03:00
c0mrade 1b376baeca fix: add retry queue to cabinet subscription operation RemnaWave errors 2026-04-10 11:56:48 +03:00
c0mrade 91a756a33e fix: add retry queue to payment webhook and renewal service RemnaWave errors 2026-04-10 11:56:24 +03:00
c0mrade 970dc549df fix: add retry queue to classic mode bot purchase handler 2026-04-10 11:40:20 +03:00
c0mrade 65120f0bad fix: add retry queue to daily subscription service RemnaWave errors 2026-04-10 11:39:39 +03:00
c0mrade 9cb559ff39 fix: enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10)
Add remnawave_retry_queue.enqueue() calls in all 10 purchase error handlers
where RemnaWave API failure was caught and swallowed without scheduling a retry:
- cabinet purchase.py: purchase_tariff() and activate_trial() (2 places)
- subscription_purchase_service.py: miniapp purchase flow (1 place)
- tariff_purchase.py: custom, standard, daily, renewal, switch, daily-switch,
  and instant-switch flows (7 places)
2026-04-10 11:04:02 +03:00
c0mrade 8f1882f24c feat: start RemnaWave retry queue on app startup 2026-04-10 10:48:14 +03:00
c0mrade 8542a39305 fix: always sync squads in auto-purchase renewal (BUG-4) 2026-04-10 10:48:01 +03:00
c0mrade 646ac4cfa1 fix: match tariff_id when creating subscriptions from panel sync (BUG-11) 2026-04-10 10:41:50 +03:00
c0mrade abdf296767 feat: add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) 2026-04-10 10:41:49 +03:00
c0mrade a1b6d9bb61 fix: use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) 2026-04-10 10:38:00 +03:00
c0mrade cf19e4e1f7 fix: protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) 2026-04-10 10:36:16 +03:00
c0mrade 35412e9f21 fix: sync connected_squads from panel during sync (BUG-5) 2026-04-10 10:36:02 +03:00
c0mrade 6aed7d355b fix: default sync_squads=True in update_remnawave_user (BUG-4) 2026-04-10 10:34:56 +03:00
c0mrade 9c08ce6948 fix: resync RemnaWave after account merge (BUG-7) 2026-04-10 10:33:45 +03:00
c0mrade 862352139e fix: use 'is not None' for telegram_id in create_user API (BUG-9) 2026-04-10 10:33:25 +03:00
c0mrade d465ccb3ac fix: resync RemnaWave after Telegram account linking (BUG-1) 2026-04-10 10:32:39 +03:00
c0mrade b57f185258 feat: add remnawave_resync_service for identity-change sync
Introduces resync_user_subscriptions_with_panel(), a standalone async
helper that re-pushes all active subscriptions to the RemnaWave panel
after any identity change (TG linking, account merge, email verification).
Handles multi-tariff vs. single-tariff mode, create vs. update branching,
tariff eager-loading, and returns a synced/failed/total stats dict.
2026-04-10 10:31:12 +03:00
c0mrade ffbb3fb8be Merge pull request #2861 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.45.2
2026-04-08 18:46:58 +03:00
github-actions[bot] f01dbff000 chore(main): release 3.45.2 2026-04-08 15:44:09 +00:00
c0mrade 31adcfded4 Merge pull request #2860 from BEDOLAGA-DEV/dev
fix: batch bug fixes from user complaints
2026-04-08 18:42:47 +03:00
c0mrade 78f963bf5e fix: batch bug fixes from user complaints
- 100% discount: daily tariff fallback to smallest configured period discount
- 100% discount: purchase blocked by safety guard (base_price → original_total in 6 guards)
- Gift subscription reset existing days (replace → extend for active/trial subs)
- Cabinet broadcast: target alias active_subscribers not mapped to active
- Promo code: error always "expired" — split into inactive/not_yet_valid/expired
- Multi-tariff: add delete subscription button in admin bot
- Multi-tariff → single: select subscription with most remaining time (end_date DESC)
- Gift purchases not counted in total spent (added GIFT_PAYMENT type)
- Remnawave API: retry on 502/503/504 (was only 429)
- Heleket: add from_referral_code to invoice payload
- Whitespace fix in blacklist_service
2026-04-08 18:33:17 +03:00
Egor 357d94d1b0 Merge pull request #2855 from andreycoast/fix/blacklist-parsing-logic
fix: исправление парсинга черного списка (поддержка '#' и извлечение username)
2026-04-07 15:49:54 +03:00
Egor 0fb4a2c235 Merge pull request #2856 from BEDOLAGA-DEV/main
w
2026-04-07 15:49:20 +03:00
andreycoast 2f7184627a fix: исправление парсинга черного списка (поддержка '#' и извлечение username) 2026-04-07 15:38:32 +03:00
67 changed files with 6633 additions and 5123 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.45.1"
".": "3.46.0"
}
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## [3.46.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.2...v3.46.0) (2026-04-13)
### New Features
* add broadcast category (system/news/promo) + filter recipients by user prefs ([931abfe](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/931abfe7a5a7fb70e9638fbf6b566fa8d1a837e4))
* add category field to broadcast API schemas and routes ([0300044](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/0300044b009f3e4b3aa3928652dfaf261a387dbc))
* add RemnaWave retry queue for failed API calls (BUG-2, BUG-10) ([abdf296](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/abdf2967675975e90f0c4d834f129281c1c28e7b))
* add remnawave_resync_service for identity-change sync ([b57f185](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b57f185258be050d945cec5989ad6dc710980a6a))
* add traffic % warning check using user's threshold preference ([1d96f80](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1d96f80f60ca445eb5108e7bc54e00d022a4cc9e))
* add user notification preferences helper utility ([e0e2edf](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e0e2edf81659fbeea361046d1bb2718c2149d884))
* implement low balance alert + respect user notification preferences ([4e50419](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4e50419171176ee452371ff095bdfead3879e554))
* respect user subscription_expiry notification preferences ([63fdfe4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/63fdfe4a421942b26caca35d8bd9b1d65f1fe7e2))
* respect user traffic_warning notification preference in webhook handler ([7208a52](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7208a52c9424d39757187fc86eec2c3460a2cdbb))
* save campaign_slug during standalone email registration ([a8e2b62](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a8e2b62f4bb0833ca32446b34ad4e0c5615fcd2a))
* start RemnaWave retry queue on app startup ([8f1882f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8f1882f24c7d066e2d0fc756f38ce23bf082687e))
### Bug Fixes
* add retry queue to all remaining RemnaWave error handlers ([7e920fa](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7e920fa30fc8e61ed2dd31e7a09151d57b7361ca))
* add retry queue to cabinet subscription operation RemnaWave errors ([1b376ba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1b376baeca120970b1ffcd406b1bbf7d9d43cee0))
* add retry queue to classic mode bot purchase handler ([970dc54](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/970dc549dfa06ba9945d5bd861374058acdca86b))
* add retry queue to daily subscription service RemnaWave errors ([65120f0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/65120f0badc9a4581ba0a127acdae8a0b23e8501))
* add retry queue to payment webhook and renewal service RemnaWave errors ([91a756a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/91a756a33ed4ce685bdf485cdb4e91c3e08799dd))
* add TRAFFIC_WARNING_ALERT and LOW_BALANCE_ALERT localization keys to all locales ([2321667](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2321667ecbe76bfe8dd37213e0bb4e45104e0fc5))
* always sync squads in auto-purchase renewal (BUG-4) ([8542a39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8542a393055a93d320d5c8c6d3aa7cc291cf8def))
* default sync_squads=True in update_remnawave_user (BUG-4) ([6aed7d3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6aed7d355bc47c4dbd2aa761d78a5e5421c32edf))
* enforce max_attempts limit in NaloGO receipt queue ([16d9163](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/16d91638bc149c5eee8f4cdd266cbd195c411030))
* enqueue retry on RemnaWave API failure in all purchase flows (BUG-2, BUG-10) ([9cb559f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9cb559ff3994c0f1c6ba48a4ec09dec9b391e48b))
* exclude users with active subscriptions from expired broadcast ([1eeeb39](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/1eeeb39779982ebb2700cb7523760631229407da))
* handle TelegramBadRequest when deleting old ticket notifications ([eb18b3a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/eb18b3a0f9ac3a617a1b21d3293e1619980a2a71))
* match tariff_id when creating subscriptions from panel sync (BUG-11) ([646ac4c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/646ac4cfa18f738040fbc6498c5d86c1546e2b9a))
* protect OAuth users with remnawave_uuid from sync deactivation (BUG-6) ([cf19e4e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cf19e4e1f7148b21d9a8072f7a0b4ae97fd04e8a))
* raise MAX_BUTTONS_PER_ROW to 8 and allow tg:// deep links in menu editor ([570af82](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/570af82dfdec980f42be96c8f816e1677f896f81))
* resync RemnaWave after account merge (BUG-7) ([9c08ce6](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/9c08ce69485b78f8fe818500e05ab6995115166a))
* resync RemnaWave after Telegram account linking (BUG-1) ([d465ccb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/d465ccb3ac3a86add6313d6bb61d53d0fe143d5e))
* sync connected_squads from panel during sync (BUG-5) ([35412e9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/35412e9f215680c0fdf1c55b5bf23496f662935c))
* trial activation fallback to trial-eligible servers when tariff has no squads (BUG-12) + fix misleading button text ([be32010](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/be32010d63966498bc6216823a75d328346d9a37))
* upsert refresh tokens (ON CONFLICT) + periodic cleanup of expired/revoked tokens ([fb8d2b3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/fb8d2b3ee4566823840100b96fe2f3bc7d41edb7))
* use 'is not None' for telegram_id in create_user API (BUG-9) ([8623521](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/862352139e8a3545e144b0618fed5011470a9a67))
* use MAX_DEVICES_LIMIT instead of hardcoded 10 for device buttons ([bc3893b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bc3893b934f0d4e5059eedbd03cdfbc628852ac1))
* use update_remnawave_user when UUID exists in tariff_purchase (BUG-3) ([a1b6d9b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a1b6d9bb619ec3de038647fe6f2e5d38979298a8))
## [3.45.2](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.1...v3.45.2) (2026-04-08)
### Bug Fixes
* batch bug fixes from user complaints ([31adcfd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/31adcfded4b161bf515d4d6b25b4395e543208f4))
* batch bug fixes from user complaints ([78f963b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/78f963bf5e7b3439d7614584c4041f88be5beb4a))
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([357d94d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/357d94d1b0d7fc8036b00cbb6b75175c29821751))
* исправление парсинга черного списка (поддержка '#' и извлечение username) ([2f71846](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2f7184627a0fb598a8c0208905cdecf0e4bb04a7))
## [3.45.1](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.45.0...v3.45.1) (2026-04-03)
+1 -1
View File
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
FROM python:3.13-slim
ARG VERSION="v3.45.1" # x-release-please-version
ARG VERSION="v3.46.0" # x-release-please-version
ARG BUILD_DATE
ARG VCS_REF
+16
View File
@@ -280,12 +280,28 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
except Exception as e:
logger.warning('Failed to load menu layout cache', error=e)
try:
from app.services.remnawave_retry_queue import remnawave_retry_queue
await remnawave_retry_queue.start()
logger.info('RemnaWave retry queue запущен')
except Exception as e:
logger.error('Ошибка запуска RemnaWave retry queue', error=e)
logger.info('Бот успешно настроен')
return bot, dp
async def shutdown_bot():
try:
from app.services.remnawave_retry_queue import remnawave_retry_queue
await remnawave_retry_queue.stop()
logger.info('RemnaWave retry queue остановлен')
except Exception as e:
logger.error('Ошибка остановки RemnaWave retry queue', error=e)
try:
await maintenance_service.stop_monitoring()
logger.info('Мониторинг техработ остановлен')
+37
View File
@@ -622,6 +622,24 @@ async def link_telegram(
telegram_id=telegram_id,
user_id=user.id,
)
# BUG-1 fix: Sync all subscriptions with RemnaWave panel so it knows the new telegram_id
try:
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
resync_result = await resync_user_subscriptions_with_panel(db, user)
logger.info(
'Post-TG-link resync completed',
user_id=user.id,
telegram_id=telegram_id,
synced=resync_result['synced'],
failed=resync_result['failed'],
)
except Exception as resync_error:
logger.error(
'Post-TG-link resync failed (non-fatal)',
user_id=user.id,
error=resync_error,
)
return LinkCallbackResponse(success=True, message='linked')
@@ -867,6 +885,25 @@ async def execute_merge_endpoint(
detail='Failed to load merged user',
)
# BUG-7 fix: Resync merged user's subscriptions with RemnaWave panel
try:
from app.services.remnawave_resync_service import resync_user_subscriptions_with_panel
resync_result = await resync_user_subscriptions_with_panel(db, merged_user)
logger.info(
'Post-merge resync completed',
primary_user_id=primary_user_id,
secondary_user_id=secondary_user_id,
synced=resync_result['synced'],
failed=resync_result['failed'],
)
except Exception as resync_error:
logger.error(
'Post-merge resync failed (non-fatal)',
primary_user_id=primary_user_id,
error=resync_error,
)
# 5. Create auth tokens for the merged user
try:
auth_response = await _create_auth_response(merged_user, db)
+5
View File
@@ -141,6 +141,7 @@ def _serialize_broadcast(broadcast: BroadcastHistory) -> BroadcastResponse:
created_at=broadcast.created_at,
completed_at=broadcast.completed_at,
progress_percent=progress,
category=getattr(broadcast, 'category', 'system') or 'system',
channel=getattr(broadcast, 'channel', 'telegram') or 'telegram',
email_subject=getattr(broadcast, 'email_subject', None),
email_html_content=getattr(broadcast, 'email_html_content', None),
@@ -432,6 +433,7 @@ async def create_broadcast(
status='queued',
admin_id=admin.id,
admin_name=admin.username or f'Admin #{admin.id}',
category=request.category,
)
db.add(broadcast)
await db.commit()
@@ -454,6 +456,7 @@ async def create_broadcast(
media=media_config,
initiator_name=admin.username or f'Admin #{admin.id}',
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
category=request.category,
)
# Start broadcast
@@ -626,6 +629,7 @@ async def create_combined_broadcast(
status='queued',
admin_id=admin.id,
admin_name=admin_name,
category=request.category,
channel=request.channel,
email_subject=request.email_subject.strip() if request.email_subject else None,
email_html_content=request.email_html_content.strip() if request.email_html_content else None,
@@ -653,6 +657,7 @@ async def create_combined_broadcast(
media=media_config,
initiator_name=admin_name,
custom_buttons=[btn.model_dump() for btn in request.custom_buttons] if request.custom_buttons else None,
category=request.category,
)
await broadcast_service.start_broadcast(broadcast.id, telegram_config)
+3 -3
View File
@@ -42,9 +42,9 @@ router = APIRouter(prefix='/admin/menu-layout', tags=['Admin Menu Layout'])
# ---- Constants ---------------------------------------------------------------
MAX_ROWS = 20
MAX_BUTTONS_PER_ROW = 3
MAX_BUTTONS_PER_ROW = 8 # Telegram inline keyboard limit
MAX_LABEL_LENGTH = 100
URL_PATTERN = re.compile(r'^https?://')
URL_PATTERN = re.compile(r'^(https?://|tg://)')
# ---- Schemas -----------------------------------------------------------------
@@ -275,7 +275,7 @@ def _validate_update_payload(rows: list[RowConfig]) -> None:
if not btn.url or not URL_PATTERN.match(btn.url):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Custom button "{btn.id}" must have a URL starting with http:// or https://.',
detail=f'Custom button "{btn.id}" must have a URL starting with http://, https://, or tg://.',
)
if btn.open_in == 'webapp' and not btn.url.startswith('https://'):
raise HTTPException(
+16 -2
View File
@@ -4,14 +4,14 @@ from __future__ import annotations
import asyncio
from datetime import datetime
from typing import Any
from typing import Any, ClassVar
import structlog
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, validator
from sqlalchemy.ext.asyncio import AsyncSession
from app.bot_factory import create_bot
@@ -128,6 +128,20 @@ class PromoOfferBroadcastRequest(BaseModel):
message_text: str | None = Field(None, description='Custom message text (HTML)')
button_text: str | None = Field(None, description='Button text')
_TARGET_ALIASES: ClassVar[dict[str, str]] = {
'no_sub': 'no',
'all_users': 'all',
'active_subscribers': 'active',
'trial_users': 'trial',
}
@validator('target')
def normalize_target(cls, value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().lower()
return cls._TARGET_ALIASES.get(normalized, normalized)
class PromoOfferBroadcastResponse(BaseModel):
created_offers: int
+29 -10
View File
@@ -144,22 +144,28 @@ async def _store_refresh_token(
refresh_token: str,
device_info: str | None = None,
) -> None:
"""Store refresh token hash in database."""
"""Store refresh token hash in database using upsert to avoid duplicate key errors."""
from sqlalchemy.dialects.postgresql import insert as pg_insert
token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
expires_at = get_refresh_token_expires_at()
token_record = CabinetRefreshToken(
stmt = pg_insert(CabinetRefreshToken).values(
user_id=user_id,
token_hash=token_hash,
device_info=device_info,
expires_at=expires_at,
)
db.add(token_record)
try:
await db.commit()
except IntegrityError:
await db.rollback()
logger.debug('Refresh token already exists (duplicate)', user_id=user_id)
stmt = stmt.on_conflict_do_update(
index_elements=['token_hash'],
set_={
'expires_at': expires_at,
'device_info': device_info,
'revoked_at': None,
},
)
await db.execute(stmt)
await db.commit()
async def _process_campaign_bonus(
@@ -1052,6 +1058,10 @@ async def register_email_standalone(
referred_by_id=referrer.id if referrer else None,
)
# Сохранить campaign_slug для обработки при верификации email
if request.campaign_slug:
user.pending_campaign_slug = request.campaign_slug
# Для тестового email или отключённой верификации - автоматически верифицировать
if is_test_email or not settings.is_cabinet_email_verification_enabled():
user.email_verified = True
@@ -1063,6 +1073,11 @@ async def register_email_standalone(
await _sync_subscription_from_panel_by_email(db, user)
except Exception:
logger.warning('Failed to sync panel subscription after auto-verify', user_id=user.id, exc_info=True)
# Process campaign bonus immediately for auto-verified users
if request.campaign_slug:
await _process_campaign_bonus(db, user, request.campaign_slug)
user.pending_campaign_slug = None
await db.commit()
else:
# Сгенерировать токен верификации
verification_token = generate_verification_token()
@@ -1173,8 +1188,12 @@ async def verify_email(
response = await _create_auth_response(user, db)
await _store_refresh_token(db, user.id, response.refresh_token)
# Process campaign bonus
response.campaign_bonus = await _process_campaign_bonus(db, user, request.campaign_slug)
# Process campaign bonus (prefer request param, fallback to saved slug from registration)
effective_campaign_slug = request.campaign_slug or user.pending_campaign_slug
response.campaign_bonus = await _process_campaign_bonus(db, user, effective_campaign_slug)
if user.pending_campaign_slug:
user.pending_campaign_slug = None
await db.commit()
if response.campaign_bonus:
response.user = _user_to_response(user)
+2
View File
@@ -79,6 +79,8 @@ async def activate_promocode(
error_messages = {
'not_found': 'Promo code not found',
'expired': 'Promo code has expired',
'inactive': 'Promo code is deactivated',
'not_yet_valid': 'Promo code is not yet active',
'used': 'Promo code has been fully used',
'already_used_by_user': 'You have already used this promo code',
'active_discount_exists': 'You already have an active discount. Deactivate it first via /deactivate-discount',
@@ -168,6 +168,13 @@ async def toggle_subscription_pause(
)
except Exception as e:
logger.error('Error syncing RemnaWave user on resume', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create',
)
if new_paused_state:
message = 'Daily subscription paused'
@@ -234,6 +234,13 @@ async def purchase_devices_legacy(
await service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync devices with RemnaWave (legacy endpoint)', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _resolve_panel_uuid(subscription, user) else 'create',
)
# Отправляем уведомление админам
try:
@@ -475,6 +482,13 @@ async def purchase_devices(
await service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync devices with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _resolve_panel_uuid(subscription, user) else 'create',
)
await db.refresh(user)
@@ -676,8 +676,10 @@ async def purchase_tariff(
promo_offer_discount_value = result.promo_offer_discount
price_before_promo_offer = price_kopeks + promo_offer_discount_value
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth)
if price_kopeks <= 0 and result.base_price <= 0 and not is_daily_tariff:
# Safety guard: reject zero-price purchases for non-daily tariffs (defense in depth).
# Use original_total (pre-discount price) — base_price is already discounted,
# so a 100% group discount legitimately makes it 0.
if price_kopeks <= 0 and result.original_total <= 0 and not is_daily_tariff:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid tariff period or pricing configuration',
@@ -909,6 +911,13 @@ async def purchase_tariff(
)
except Exception as remnawave_error:
logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if not subscription.remnawave_uuid else 'update',
)
# Save cart for auto-renewal (not for daily tariffs - they have their own charging)
if not is_daily_tariff:
@@ -1226,6 +1235,13 @@ async def activate_trial(
except Exception as e:
logger.error('Error getting trial tariff', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
# Create trial subscription
subscription = await create_trial_subscription(
db=db,
@@ -1247,6 +1263,13 @@ async def activate_trial(
await db.refresh(subscription)
except Exception as e:
logger.error('Failed to create RemnaWave user for trial', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create',
)
# Send admin notification about trial activation
try:
@@ -67,7 +67,7 @@ async def get_renewal_options(
for period in periods:
pricing = await pricing_engine.calculate_renewal_price(db, subscription, period, user=user)
if pricing.final_total <= 0 and pricing.base_price <= 0:
if pricing.final_total <= 0 and pricing.original_total <= 0:
continue
original_price = pricing.original_total
@@ -155,7 +155,7 @@ async def renew_subscription(
promo_offer_discount_value = pricing.promo_offer_discount
promo_offer_discount_percent = pricing.breakdown.get('offer_discount_pct', 0)
if price_kopeks <= 0 and pricing.base_price <= 0:
if price_kopeks <= 0 and pricing.original_total <= 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Invalid renewal period',
@@ -249,6 +249,13 @@ async def update_countries(
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync countries with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _has_panel else 'create',
)
await db.refresh(subscription)
@@ -428,6 +428,13 @@ async def switch_tariff(
)
except Exception as e:
logger.error('Failed to sync tariff switch with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _has_panel else 'create',
)
# Reset all devices on tariff switch
devices_reset = False
@@ -329,6 +329,13 @@ async def purchase_traffic(
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync traffic with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='update' if _panel_uuid else 'create',
)
# Создаём транзакцию
await create_transaction(
@@ -615,6 +622,14 @@ async def switch_traffic_package(
await subscription_service.create_remnawave_user(db, subscription)
except Exception as e:
logger.error('Failed to sync traffic switch with RemnaWave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update' if _panel_uuid2 else 'create',
)
await db.refresh(user)
await db.refresh(subscription)
+3
View File
@@ -138,6 +138,9 @@ class EmailRegisterStandaloneRequest(BaseModel):
referral_code: str | None = Field(
None, max_length=32, pattern=r'^[a-zA-Z0-9_-]+$', description='Referral code of inviter'
)
campaign_slug: str | None = Field(
None, min_length=1, max_length=64, pattern=r'^[a-zA-Z0-9_-]+$', description='Campaign slug from web link'
)
class CampaignBonusInfo(BaseModel):
+7
View File
@@ -118,6 +118,7 @@ class BroadcastCreateRequest(BaseModel):
selected_buttons: list[str] = Field(default_factory=lambda: ['home'])
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
category: str = Field(default='system', pattern='^(system|news|promo)$')
# ============ Response ============
@@ -144,6 +145,9 @@ class BroadcastResponse(BaseModel):
completed_at: datetime | None = None
progress_percent: float = 0.0
# Category for user notification preference filtering
category: str = 'system' # system|news|promo
# Email/channel fields
channel: str = 'telegram' # telegram|email|both
email_subject: str | None = None
@@ -212,6 +216,9 @@ class CombinedBroadcastCreateRequest(BaseModel):
custom_buttons: list[CustomBroadcastButton] = Field(default_factory=list, max_length=10)
media: BroadcastMediaRequest | None = None
# Broadcast category for user notification preference filtering
category: str = Field(default='system', pattern='^(system|news|promo)$')
# Email-specific fields
email_subject: str | None = Field(default=None, max_length=255)
email_html_content: str | None = Field(default=None, max_length=100000)
+2 -1
View File
@@ -96,12 +96,13 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Subscri
)
.where(Subscription.user_id == user_id)
.order_by(
# Active/trial subscriptions first, then by creation date
# Active/trial subscriptions first, then by end_date (most remaining time)
case(
(Subscription.status == SubscriptionStatus.ACTIVE.value, 0),
(Subscription.status == SubscriptionStatus.TRIAL.value, 1),
else_=2,
),
Subscription.end_date.desc().nulls_last(),
Subscription.created_at.desc(),
)
.limit(1)
+6
View File
@@ -122,6 +122,12 @@ async def clear_trial_tariff(db: AsyncSession) -> None:
await db.commit()
async def get_all_active_tariffs(db: AsyncSession) -> list[Tariff]:
"""Get all active tariffs."""
result = await db.execute(select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.tier_level))
return list(result.scalars().all())
async def get_tariffs_for_user(
db: AsyncSession,
promo_group_id: int | None = None,
+6 -1
View File
@@ -235,7 +235,12 @@ async def get_user_total_spent_kopeks(db: AsyncSession, user_id: int) -> int:
and_(
Transaction.user_id == user_id,
Transaction.is_completed.is_(True),
Transaction.type == TransactionType.SUBSCRIPTION_PAYMENT.value,
Transaction.type.in_(
[
TransactionType.SUBSCRIPTION_PAYMENT.value,
TransactionType.GIFT_PAYMENT.value,
]
),
)
)
)
+12
View File
@@ -933,6 +933,13 @@ class PromoGroup(Base):
if period_days in discounts:
return discounts[period_days]
# For daily tariffs (period_days=1): fallback to the smallest configured period discount.
# Admins configure discounts for standard periods (30, 90, 180, 360) but not for daily.
# If all periods have 100% discount, daily should too.
if period_days <= 1 and discounts:
smallest_period = min(discounts)
return discounts[smallest_period]
if self.is_default:
try:
from app.config import settings
@@ -1204,6 +1211,8 @@ class User(Base):
password_reset_token = Column(String(255), nullable=True)
password_reset_expires = Column(AwareDateTime(), nullable=True)
cabinet_last_login = Column(AwareDateTime(), nullable=True)
# Campaign slug saved at registration, consumed at email verification
pending_campaign_slug = Column(String(64), nullable=True)
# Email change fields
email_change_new = Column(String(255), nullable=True) # New email pending verification
email_change_code = Column(String(6), nullable=True) # 6-digit verification code
@@ -2238,6 +2247,9 @@ class BroadcastHistory(Base):
created_at = Column(AwareDateTime(), server_default=func.now())
completed_at = Column(AwareDateTime(), nullable=True)
# Broadcast category for user notification preferences filtering
category = Column(String(20), default='system', nullable=False) # system|news|promo
# Email broadcast fields
channel = Column(String(20), default='telegram', nullable=False) # telegram|email|both
email_subject = Column(String(255), nullable=True)
+9 -8
View File
@@ -376,15 +376,16 @@ class RemnaWaveAPI:
except json.JSONDecodeError:
response_data = {'raw_response': response_text}
if response.status == 429 and attempt < max_retries:
if response.status in (429, 502, 503, 504) and attempt < max_retries:
retry_after = float(response.headers.get('Retry-After', base_delay * (2**attempt)))
logger.warning(
'Rate limited (429) on , retry / after s',
method=method,
endpoint=endpoint,
attempt=attempt + 1,
max_retries=max_retries,
retry_after=retry_after,
'Retryable %s on %s %s, retry %s/%s after %ss',
response.status,
method,
endpoint,
attempt + 1,
max_retries,
retry_after,
)
await asyncio.sleep(retry_after)
continue
@@ -445,7 +446,7 @@ class RemnaWaveAPI:
'trafficLimitStrategy': traffic_limit_strategy.value,
}
if telegram_id:
if telegram_id is not None:
data['telegramId'] = telegram_id
if email:
data['email'] = email
+14 -22
View File
@@ -1599,42 +1599,28 @@ async def get_target_users_count(db: AsyncSession, target: str) -> int:
result = await db.execute(query)
return result.scalar() or 0
if target == 'expired':
# Истекшие подписки
if target in ('expired', 'expired_subscribers'):
# Истекшие подписки — исключаем юзеров с хотя бы одной активной
now = datetime.now(UTC)
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
has_active_sub = (
select(Subscription.id)
.where(
base_filter,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
and_(Subscription.id == None, User.has_had_paid_subscription == True),
),
Subscription.user_id == User.id,
Subscription.status == SubscriptionStatus.ACTIVE.value,
)
.exists()
)
result = await db.execute(query)
return result.scalar() or 0
if target == 'expired_subscribers':
# То же что и expired
now = datetime.now(UTC)
expired_statuses = [
SubscriptionStatus.EXPIRED.value,
SubscriptionStatus.DISABLED.value,
SubscriptionStatus.LIMITED.value,
]
query = (
select(sql_func.count(distinct(User.id)))
.outerjoin(Subscription, User.id == Subscription.user_id)
.where(
base_filter,
~has_active_sub,
or_(
Subscription.status.in_(expired_statuses),
and_(Subscription.end_date <= now, Subscription.status != SubscriptionStatus.ACTIVE.value),
@@ -1785,6 +1771,9 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
for user in users:
subs = getattr(user, 'subscriptions', None) or []
if subs:
has_active = any(s.is_active for s in subs)
if has_active:
continue # Skip users who have at least one active subscription
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
if has_expired:
expired_users.append(user)
@@ -1833,6 +1822,9 @@ async def get_target_users(db: AsyncSession, target: str) -> list:
for user in users:
subs = getattr(user, 'subscriptions', None) or []
if subs:
has_active = any(s.is_active for s in subs)
if has_active:
continue # Skip users who have at least one active subscription
has_expired = any(s.status in expired_statuses or (s.end_date <= now and not s.is_active) for s in subs)
if has_expired:
expired_users.append(user)
+1 -1
View File
@@ -77,7 +77,7 @@ def _build_server_edit_view(server):
],
[
types.InlineKeyboardButton(
text='🎁 Выдавать сквад' if not server.is_trial_eligible else '🚫 Не выдавать сквад',
text='🎁 Выдавать в триал' if not server.is_trial_eligible else '🚫 Не выдавать в триал',
callback_data=f'admin_server_trial_{server.id}',
),
],
+114 -26
View File
@@ -990,13 +990,14 @@ async def _render_user_subscription_overview(
]
)
else:
keyboard.append(
[
types.InlineKeyboardButton(
text='✅ Активировать', callback_data=f'admin_sub_activate_{user_id}{_sid}'
)
]
)
row = [
types.InlineKeyboardButton(text='✅ Активировать', callback_data=f'admin_sub_activate_{user_id}{_sid}'),
]
if settings.is_multi_tariff_enabled() and subscription_id:
row.append(
types.InlineKeyboardButton(text='🗑 Удалить', callback_data=f'admin_sub_delete_{user_id}{_sid}')
)
keyboard.append(row)
else:
text += '❌ <b>Подписка отсутствует</b>\n\n'
text += 'Пользователь еще не активировал подписку.'
@@ -3302,6 +3303,76 @@ async def confirm_subscription_deactivation(callback: types.CallbackQuery, db_us
await callback.answer()
@admin_required
@error_handler
async def delete_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
"""Show confirmation for deleting a subscription (multi-tariff only)."""
user_id, subscription_id = _extract_admin_sub_context(callback.data)
if not subscription_id or not settings.is_multi_tariff_enabled():
await callback.answer('Удаление доступно только в мультитарифном режиме', show_alert=True)
return
back_cb = f'admin_user_sub_select_{user_id}_{subscription_id}'
_sid = f'_s{subscription_id}'
await callback.message.edit_text(
'🗑 <b>Удаление подписки</b>\n\n⚠️ Подписка будет полностью удалена из системы.\nЭто действие необратимо!',
reply_markup=get_confirmation_keyboard(f'admin_sub_delete_confirm_{user_id}{_sid}', back_cb, db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def confirm_subscription_deletion(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
"""Delete a subscription permanently (multi-tariff only)."""
user_id, subscription_id = _extract_admin_sub_context(callback.data)
if not subscription_id or not settings.is_multi_tariff_enabled():
await callback.answer('Удаление доступно только в мультитарифном режиме', show_alert=True)
return
from app.database.crud.subscription import get_subscription_by_id_for_user
subscription = await get_subscription_by_id_for_user(db, subscription_id, user_id)
if not subscription:
await callback.answer('Подписка не найдена', show_alert=True)
return
# Disable on Remnawave side first
_uuid = getattr(subscription, 'remnawave_uuid', None)
if _uuid:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(_uuid)
# Delete traffic purchases
from sqlalchemy import delete as sql_delete
from app.database.models import TrafficPurchase
await db.execute(sql_delete(TrafficPurchase).where(TrafficPurchase.subscription_id == subscription.id))
await db.delete(subscription)
await db.commit()
logger.info(
'Админ удалил подписку пользователя',
admin_id=db_user.id,
user_id=user_id,
subscription_id=subscription_id,
)
back_cb = f'admin_user_subscription_{user_id}'
await callback.message.edit_text(
'✅ Подписка удалена',
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text='📱 К подпискам', callback_data=back_cb)]]
),
)
await callback.answer()
@admin_required
@error_handler
async def activate_user_subscription(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
@@ -3750,26 +3821,38 @@ async def start_devices_edit(callback: types.CallbackQuery, db_user: User, state
else f'admin_user_subscription_{user_id}'
)
await callback.message.edit_text(
'📱 <b>Изменение количества устройств</b>\n\n'
'Введите новое количество устройств (от 1 до 10):\n'
'• Текущее значение будет заменено\n'
'• Примеры: 1, 2, 5, 10\n\n'
'Или нажмите /cancel для отмены',
reply_markup=types.InlineKeyboardMarkup(
max_dev = settings.MAX_DEVICES_LIMIT
# Build device buttons dynamically: rows of 4, respect Telegram 100 button limit (99 + cancel)
if max_dev <= 99:
device_buttons: list[list[types.InlineKeyboardButton]] = []
row: list[types.InlineKeyboardButton] = []
for i in range(1, max_dev + 1):
row.append(
types.InlineKeyboardButton(
text=str(i),
callback_data=f'admin_user_devices_set_{user_id}{_sid}_{i}',
)
)
if len(row) == 4:
device_buttons.append(row)
row = []
if row:
device_buttons.append(row)
device_buttons.append([types.InlineKeyboardButton(text='❌ Отмена', callback_data=back_cb)])
markup = types.InlineKeyboardMarkup(inline_keyboard=device_buttons)
else:
markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(text='1', callback_data=f'admin_user_devices_set_{user_id}{_sid}_1'),
types.InlineKeyboardButton(text='2', callback_data=f'admin_user_devices_set_{user_id}{_sid}_2'),
types.InlineKeyboardButton(text='3', callback_data=f'admin_user_devices_set_{user_id}{_sid}_3'),
],
[
types.InlineKeyboardButton(text='5', callback_data=f'admin_user_devices_set_{user_id}{_sid}_5'),
types.InlineKeyboardButton(text='10', callback_data=f'admin_user_devices_set_{user_id}{_sid}_10'),
],
[types.InlineKeyboardButton(text='❌ Отмена', callback_data=back_cb)],
]
),
)
await callback.message.edit_text(
'📱 <b>Изменение количества устройств</b>\n\n'
f'Введите новое количество устройств (от 1 до {max_dev}):\n'
'• Текущее значение будет заменено\n\n'
'Или нажмите /cancel для отмены',
reply_markup=markup,
)
await state.set_state(AdminStates.editing_user_devices)
@@ -3839,8 +3922,8 @@ async def process_devices_edit_text(message: types.Message, db_user: User, state
try:
devices = int(message.text.strip())
if devices <= 0 or devices > 10:
await message.answer('❌ Количество устройств должно быть от 1 до 10')
if devices <= 0 or devices > settings.MAX_DEVICES_LIMIT:
await message.answer(f'❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}')
return
success = await _update_user_devices(db, user_id, devices, db_user.id, subscription_id=subscription_id)
@@ -5942,6 +6025,11 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(activate_user_subscription, F.data.startswith('admin_sub_activate_'))
dp.callback_query.register(
delete_user_subscription, F.data.startswith('admin_sub_delete_') & ~F.data.contains('confirm')
)
dp.callback_query.register(confirm_subscription_deletion, F.data.startswith('admin_sub_delete_confirm_'))
dp.callback_query.register(grant_trial_subscription, F.data.startswith('admin_sub_grant_trial_'))
dp.callback_query.register(
+2
View File
@@ -197,6 +197,8 @@ async def process_promocode(message: types.Message, db_user: User, state: FSMCon
error_messages = {
'not_found': texts.PROMOCODE_INVALID,
'expired': texts.PROMOCODE_EXPIRED,
'inactive': texts.t('PROMOCODE_INACTIVE', '❌ Промокод деактивирован'),
'not_yet_valid': texts.t('PROMOCODE_NOT_YET_VALID', '❌ Промокод ещё не начал действовать'),
'used': texts.PROMOCODE_USED,
'already_used_by_user': texts.PROMOCODE_USED,
'not_first_purchase': texts.t(
+16
View File
@@ -549,6 +549,14 @@ async def handle_simple_subscription_pay_with_balance(
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Отправляем уведомление об успешной покупке
server_label = _get_simple_subscription_server_label(
@@ -2289,6 +2297,14 @@ async def confirm_simple_subscription_purchase(
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Отправляем уведомление об успешной покупке
server_label = _get_simple_subscription_server_label(
+8
View File
@@ -253,6 +253,14 @@ async def _handle_trial_payment(
except Exception as rw_error:
logger.error('Ошибка создания пользователя RemnaWave для триала', rw_error=rw_error)
# Не откатываем подписку, просто логируем - RemnaWave может быть временно недоступен
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
await db.commit()
await db.refresh(user)
+20
View File
@@ -234,6 +234,14 @@ async def _claim_phantom_user(
subscription_id=phantom_sub.id,
error=str(exc),
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(phantom_sub, 'id') and hasattr(phantom_sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=phantom_sub.id,
user_id=phantom_sub.user_id,
action='update',
)
return True, phantom
@@ -2443,6 +2451,18 @@ async def required_sub_channel_check(
telegram_id=user.telegram_id if user else query.from_user.id,
api_error=api_error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
for sub in _subs:
if sub.is_trial and sub.status == SubscriptionStatus.ACTIVE.value:
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update'
if (getattr(sub, 'remnawave_uuid', None) or user.remnawave_uuid)
else 'create',
)
await query.answer(
texts.t('CHANNEL_SUBSCRIBE_THANKS', '✅ Спасибо за подписку'),
+12 -1
View File
@@ -412,7 +412,18 @@ async def apply_countries_changes(callback: types.CallbackQuery, db_user: User,
await db.commit()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
try:
await subscription_service.update_remnawave_user(db, subscription, sync_squads=True)
except Exception as rw_err:
logger.error('Ошибка синхронизации с RemnaWave при смене стран', error=rw_err)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
await db.refresh(subscription)
+46 -8
View File
@@ -937,6 +937,13 @@ async def activate_trial(callback: types.CallbackQuery, db_user: User, db: Async
except Exception as e:
logger.error('Ошибка получения триального тарифа', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
subscription = await create_trial_subscription(
db,
db_user.id,
@@ -1735,8 +1742,8 @@ async def handle_extend_subscription(
# original = price before ALL discounts, final = price with all discounts
total_original_price = pricing.original_total
# Пропускаем периоды с нулевой ценой — защита от бесплатного продления
if pricing.final_total <= 0 and pricing.base_price <= 0:
# Пропускаем периоды с нулевой ценой (если оригинальная цена тоже 0 — не настроен)
if pricing.final_total <= 0 and pricing.original_total <= 0:
continue
renewal_prices[days] = {
@@ -2607,12 +2614,22 @@ async def confirm_purchase(callback: types.CallbackQuery, state: FSMContext, db_
if not remnawave_user:
logger.error('Не удалось создать/обновить RemnaWave пользователя для', telegram_id=db_user.telegram_id)
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки (повторная попытка)',
)
try:
remnawave_user = await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка подписки (повторная попытка)',
)
except Exception as retry_error:
logger.error('Повторная попытка создания RemnaWave пользователя также не удалась', error=retry_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
transaction = await create_transaction(
db=db,
@@ -3162,6 +3179,13 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
)
except Exception as e:
logger.error('Ошибка синхронизации с Remnawave при возобновлении', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='update',
)
# Отправляем уведомление администраторам о возобновлении суточной подписки
if resume_transaction is not None:
@@ -3303,6 +3327,13 @@ async def handle_trial_pay_with_balance(callback: types.CallbackQuery, db_user:
except Exception as e:
logger.error('Ошибка получения триального тарифа для платного триала', error=e)
# BUG-12 fix: If no squads from tariff, fallback to trial-eligible servers
if not trial_squads:
from app.database.crud.server_squad import get_random_trial_squad_uuid
trial_squad_uuid = await get_random_trial_squad_uuid(db)
trial_squads = [trial_squad_uuid] if trial_squad_uuid else []
subscription = await create_trial_subscription(
db,
db_user.id,
@@ -4594,6 +4625,13 @@ async def _extend_existing_subscription(
logger.error('⚠ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE')
except Exception as e:
logger.error('⚠ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=current_subscription.id,
user_id=db_user.id,
action='update',
)
# Создаём транзакцию
transaction = await create_transaction(
+177 -37
View File
@@ -928,8 +928,8 @@ async def handle_custom_confirm(
)
total_price = result.final_total
# Проверяем, что цена за период валидна
if result.base_price == 0 and not tariff.can_purchase_custom_days():
# Проверяем, что цена за период валидна (original_total — цена до скидок)
if result.original_total == 0 and not tariff.can_purchase_custom_days():
await callback.answer('Выбранный период недоступен для этого тарифа', show_alert=True)
return
@@ -1057,14 +1057,34 @@ async def handle_custom_confirm(
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(db_user, 'remnawave_uuid', None)
)
if _panel_uuid:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
await create_transaction(
@@ -1568,14 +1588,34 @@ async def confirm_tariff_purchase(
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(db_user, 'remnawave_uuid', None)
)
if _panel_uuid:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
try:
@@ -1828,14 +1868,34 @@ async def confirm_daily_tariff_purchase(
# При покупке тарифа ВСЕГДА сбрасываем трафик в панели
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка суточного тарифа',
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(db_user, 'remnawave_uuid', None)
)
if _panel_uuid:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка суточного тарифа',
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=True,
reset_reason='покупка суточного тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
await create_transaction(
@@ -2280,14 +2340,34 @@ async def confirm_tariff_extend(
# Обновляем пользователя в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(db_user, 'remnawave_uuid', None)
)
if _panel_uuid:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT or was_trial,
reset_reason='конвертация триала' if was_trial else 'продление тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Создаем транзакцию
await create_transaction(
@@ -2893,14 +2973,34 @@ async def confirm_tariff_switch(
# Обновляем пользователя в Remnawave
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(db_user, 'remnawave_uuid', None)
)
if _panel_uuid:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='переключение тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave при переключении тарифа', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
@@ -3121,14 +3221,34 @@ async def confirm_daily_tariff_switch(
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(db_user, 'remnawave_uuid', None)
)
if _panel_uuid:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='смена на суточный тариф',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
@@ -3795,14 +3915,34 @@ async def confirm_instant_switch(
# Обновляем пользователя в Remnawave (сброс трафика по админ-настройке)
try:
subscription_service = SubscriptionService()
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
_panel_uuid = (
subscription.remnawave_uuid
if settings.is_multi_tariff_enabled() and subscription.remnawave_uuid
else getattr(db_user, 'remnawave_uuid', None)
)
if _panel_uuid:
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_TARIFF_SWITCH,
reset_reason='мгновенное переключение тарифа',
)
except Exception as e:
logger.error('Ошибка обновления Remnawave при мгновенном переключении', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=db_user.id,
action='create',
)
# Гарантированный сброс устройств при смене тарифа
await db.refresh(db_user)
+8 -1
View File
@@ -983,7 +983,14 @@ async def close_ticket_notification(callback: types.CallbackQuery, db_user: User
await callback.answer()
return
await callback.message.delete()
try:
await callback.message.delete()
except TelegramBadRequest:
# Message is too old to delete (>48h) — edit it instead
try:
await callback.message.edit_text(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
except TelegramBadRequest:
pass
await callback.answer(texts.t('NOTIFICATION_CLOSED', 'Уведомление закрыто.'))
+3 -1
View File
@@ -1756,5 +1756,7 @@
"WEBHOOK_DEVICE_ADDED": "📱 <b>New device</b>\n\nA new device has been added to your subscription{tariff_label}: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Device removed</b>\n\nA device has been removed from your subscription{tariff_label}: <code>{device}</code>",
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Torrent detected</b>\n\nTorrent traffic was detected on your connection{tariff_label}. Using torrents may result in subscription restrictions.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Close"
"WEBHOOK_CLOSE_BUTTON": "✖️ Close",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Traffic Warning</b>\n\nUsed: {used:.1f} / {limit} GB ({percent:.0f}%)\n\nYour traffic limit is almost reached.",
"LOW_BALANCE_ALERT": "⚠️ <b>Low Balance</b>\n\nYour balance: {balance} ₽\nNotification threshold: {threshold} ₽\n\nTop up your balance to ensure automatic subscription renewal."
}
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -1749,19 +1749,16 @@
"MODEM_PRICE_WITH_DISCOUNT": "Стоимость: <s>{base_price}</s> <b>{final_price}</b> (за {months} мес)\n🎁 Скидка {discount}%: -{discount_amount}",
"MODEM_PRICE_NO_DISCOUNT": "Стоимость: {price} (за {months} мес)",
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Подтверждение подключения модема</b>\n\n{price_text}\n\nПри подключении модема:\n• К подписке добавится дополнительное устройство\n• Ежемесячная плата увеличится на {monthly_price}\n\nПодтвердить подключение?",
"ADMIN_USER_RESTRICTIONS": "⚠️ Ограничить",
"USER_RESTRICTION_TOPUP_BLOCKED": "🚫 <b>Пополнение ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_SUBSCRIPTION_BLOCKED": "🚫 <b>Покупка/продление подписки ограничено</b>\n\n{reason}\n\nЕсли вы считаете это ошибкой, вы можете обжаловать решение.",
"USER_RESTRICTION_APPEAL_BUTTON": "🆘 Обжаловать",
"PAUSE_DAILY_BUTTON": "⏸️ Приостановить подписку",
"RESUME_DAILY_BUTTON": "▶️ Возобновить подписку",
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Подписка возобновлена!</b>\n\nВаш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n💳 Списано: {amount}\n💰 Остаток: {balance}",
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка{tariff_label} истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка{tariff_label} отключена</b>\n\nВаша подписка была отключена администратором.",
"WEBHOOK_SUB_ENABLED": "✅ <b>Подписка{tariff_label} активирована</b>\n\nВаша подписка снова активна. Приятного использования!",
@@ -1780,5 +1777,7 @@
"WEBHOOK_DEVICE_ADDED": "📱 <b>Новое устройство</b>\n\nК подписке{tariff_label} подключено новое устройство: <code>{device}</code>",
"WEBHOOK_DEVICE_DELETED": "📱 <b>Устройство удалено</b>\n\nУстройство отключено от подписки{tariff_label}: <code>{device}</code>",
"WEBHOOK_TORRENT_DETECTED": "🚫 <b>Обнаружен торрент</b>\n\nВ вашем подключении{tariff_label} обнаружен торрент-трафик. Использование торрентов может привести к ограничению подписки.",
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть"
}
"WEBHOOK_CLOSE_BUTTON": "✖️ Закрыть",
"TRAFFIC_WARNING_ALERT": "⚠️ <b>Предупреждение о трафике</b>\n\nИспользовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\nВаш лимит трафика почти исчерпан.",
"LOW_BALANCE_ALERT": "⚠️ <b>Низкий баланс</b>\n\nВаш баланс: {balance} ₽\nПорог уведомления: {threshold} ₽\n\nПополните баланс, чтобы автопродление подписки прошло успешно."
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+25 -20
View File
@@ -86,33 +86,38 @@ class BlacklistService:
if not line or line.startswith('#'):
continue # Пропускаем пустые строки и комментарии
# В формате '7021477105 #@MAMYT_PAXAL2016, перепродажа подписок'
# В формате '7021477105 # @MAMYT_PAXAL2016, перепродажа подписок'
# только первая часть до пробела - это Telegram ID, всё остальное комментарий
parts = line.split()
if not parts:
continue
try:
telegram_id = int(parts[0]) # Первое число - это Telegram ID
# Всё остальное - просто комментарий, не используем его для логики
# Но можем использовать первую часть после ID как username для отображения
username = ''
if len(parts) > 1:
# Берем вторую часть как username (если начинается с @)
if parts[1].startswith('@'):
username = parts[1]
# 1. Разделяем строку на ID и всё остальное по символу '#'
if '#' in line:
id_part, content_part = line.split('#', 1)
telegram_id = int(id_part.strip())
content = content_part.strip()
else:
# Если решётки нет, пробуем просто взять первое число
parts = line.split(maxsplit=1)
telegram_id = int(parts[0])
content = parts[1].strip() if len(parts) > 1 else ''
# По умолчанию используем "Занесен в черный список", если нет другой информации
# 2. Обрабатываем контент: вычленяем username, если он есть в начале
username = ''
reason = 'Занесен в черный список'
# Если есть запятая в строке, можем использовать часть после нее как причину
full_line_after_id = line[len(str(telegram_id)) :].strip()
if ',' in full_line_after_id:
# Извлекаем причину после запятой
after_comma = full_line_after_id.split(',', 1)[1].strip()
reason = after_comma
if content:
if content.startswith('@'):
# Разбиваем контент только по первому пробелу
# content_parts[0] будет юзернеймом, content_parts[1] — причиной
content_parts = content.split(maxsplit=1)
username = content_parts[0]
if len(content_parts) > 1:
reason = content_parts[1].strip()
else:
# Если собачки нет, значит весь контент — это причина
reason = content
blacklist_data.append((telegram_id, username, reason))
except ValueError:
# Если не удается преобразовать в число, это не ID
logger.warning(
+20 -3
View File
@@ -62,6 +62,7 @@ class BroadcastConfig:
media: BroadcastMediaConfig | None = None
initiator_name: str | None = None
custom_buttons: list[dict] | None = None
category: str = 'system' # system|news|promo
@dataclass
@@ -160,7 +161,7 @@ class BroadcastService:
await session.commit()
# _fetch_recipients теперь возвращает list[int] (telegram_id), а не ORM-объекты
recipient_ids: list[int] = await self._fetch_recipients(config.target)
recipient_ids: list[int] = await self._fetch_recipients(config.target, config.category)
async with AsyncSessionLocal() as session:
broadcast = await session.get(BroadcastHistory, broadcast_id)
@@ -226,8 +227,13 @@ class BroadcastService:
logger.exception('Критическая ошибка при выполнении рассылки', broadcast_id=broadcast_id, exc=exc)
await self._mark_failed(broadcast_id, sent_count, failed_count, blocked_count)
async def _fetch_recipients(self, target: str) -> list[int]:
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты)."""
async def _fetch_recipients(self, target: str, category: str = 'system') -> list[int]:
"""Загружает получателей и возвращает список telegram_id (скаляры, не ORM-объекты).
Filters out users who disabled the given broadcast category in their
notification preferences (news_enabled, promo_offers_enabled).
Category 'system' is never filtered system notifications reach everyone.
"""
async with AsyncSessionLocal() as session:
if target.startswith('custom_'):
criteria = target[len('custom_') :]
@@ -235,6 +241,17 @@ class BroadcastService:
else:
users_orm = await get_target_users(session, target)
# Filter by user notification preferences based on broadcast category
if category == 'news':
from app.utils.notification_prefs import is_news_enabled
users_orm = [u for u in users_orm if is_news_enabled(u)]
elif category == 'promo':
from app.utils.notification_prefs import is_promo_offers_enabled
users_orm = [u for u in users_orm if is_promo_offers_enabled(u)]
# category == 'system' → no filtering, sent to everyone
# Извлекаем telegram_id сразу, пока сессия жива.
# После выхода из блока ORM-объекты станут detached.
return [u.telegram_id for u in users_orm if u.telegram_id is not None]
@@ -264,6 +264,14 @@ class DailySubscriptionService:
logger.warning('Не удалось синхронизировать сквады после создания', error=patch_err)
except Exception as e:
logger.warning('Не удалось обновить Remnawave', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update' if _has_panel_user else 'create',
)
# Отправляем уведомление администраторам
try:
@@ -539,6 +547,14 @@ class DailySubscriptionService:
await subscription_service.update_remnawave_user(db, subscription)
except Exception as e:
logger.warning('Не удалось синхронизировать с RemnaWave после сброса трафика', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Уведомляем пользователя
if self._bot and subscription.user_id:
+44 -3
View File
@@ -15,7 +15,12 @@ from app.cabinet.auth.jwt_handler import create_auto_login_token
from app.cabinet.auth.password_utils import hash_password
from app.config import settings
from app.database.crud.landing import create_guest_purchase
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription
from app.database.crud.subscription import (
create_paid_subscription,
extend_subscription,
get_subscription_by_user_id,
replace_subscription,
)
from app.database.crud.tariff import get_tariff_by_id
from app.database.crud.transaction import create_transaction
from app.database.crud.user import _get_or_create_default_promo_group
@@ -28,6 +33,7 @@ from app.database.models import (
Transaction,
TransactionType,
User,
_aware,
)
from app.services.subscription_service import SubscriptionService
@@ -1039,7 +1045,24 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
from app.database.crud.subscription import get_subscription_by_user_and_tariff
existing_for_tariff = await get_subscription_by_user_and_tariff(db, user.id, tariff.id)
if existing_for_tariff:
_has_time = (
existing_for_tariff is not None
and existing_for_tariff.end_date is not None
and _aware(existing_for_tariff.end_date) > datetime.now(UTC)
)
if existing_for_tariff and _has_time:
# Extend existing active/trial subscription instead of replacing (preserve remaining days)
subscription = await extend_subscription(
db,
existing_for_tariff,
purchase.period_days,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
commit=False,
)
elif existing_for_tariff:
# Expired subscription — replace with fresh dates
subscription = await replace_subscription(
db,
existing_for_tariff,
@@ -1066,7 +1089,25 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
)
else:
existing_subscription = await get_subscription_by_user_id(db, user.id)
if existing_subscription is not None:
_sub_has_time = (
existing_subscription is not None
and existing_subscription.end_date is not None
and _aware(existing_subscription.end_date) > datetime.now(UTC)
)
if existing_subscription is not None and _sub_has_time:
# Extend existing active subscription (preserve remaining days)
subscription = await extend_subscription(
db,
existing_subscription,
purchase.period_days,
tariff_id=tariff.id,
traffic_limit_gb=tariff.traffic_limit_gb,
device_limit=tariff.device_limit,
connected_squads=squads,
commit=False,
)
elif existing_subscription is not None:
# Expired subscription — replace with fresh dates
subscription = await replace_subscription(
db,
existing_subscription,
+212
View File
@@ -245,7 +245,10 @@ class MonitoringService:
await self._check_trial_expiring_soon(db)
await self._check_trial_channel_subscriptions(db)
await self._check_expired_subscription_followups(db)
await self._check_traffic_warnings(db)
await self._check_low_balance_alerts(db)
await self._retry_stuck_guest_purchases(db)
await self._cleanup_expired_refresh_tokens(db)
await self._cleanup_inactive_users(db)
await self._sync_with_remnawave(db)
@@ -517,11 +520,25 @@ class MonitoringService:
users_with_cards = await get_user_ids_with_active_payment_methods(db, autopay_user_ids)
from app.utils.notification_prefs import (
get_subscription_expiry_days,
is_subscription_expiry_enabled,
)
for subscription in expiring_subscriptions:
user = await get_user_by_id(db, subscription.user_id)
if not user:
continue
# Respect user notification preferences
if not is_subscription_expiry_enabled(user):
continue
# Check if user's preferred days threshold matches this check
user_expiry_days = get_subscription_expiry_days(user)
if days > user_expiry_days:
continue
# Use user.id + subscription.id for key to support multiple subscriptions per user
sub_key = f'user_{user.id}_sub_{subscription.id}_today'
user_identifier = user.telegram_id or f'email:{user.id}'
@@ -1960,6 +1977,201 @@ class MonitoringService:
except Exception:
logger.error('Error retrying stuck PENDING_ACTIVATION guest purchases', exc_info=True)
async def _check_traffic_warnings(self, db: AsyncSession):
"""Check subscriptions approaching traffic limit and notify users."""
if not self.bot:
return
try:
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.database.models import Subscription
from app.utils.notification_prefs import get_traffic_warning_percent, is_traffic_warning_enabled
# Get active subscriptions with traffic limits (not unlimited)
result = await db.execute(
select(Subscription)
.options(selectinload(Subscription.user))
.where(
Subscription.status.in_(['active', 'trial']),
Subscription.traffic_limit_gb > 0,
)
)
subscriptions = result.scalars().all()
sent_count = 0
for subscription in subscriptions:
user = subscription.user
if not user or not user.telegram_id:
continue
if not is_traffic_warning_enabled(user):
continue
traffic_limit = subscription.traffic_limit_gb or 0
traffic_used = subscription.traffic_used_gb or 0.0
if traffic_limit <= 0:
continue
current_percent = (traffic_used / traffic_limit) * 100
user_threshold = get_traffic_warning_percent(user)
if current_percent < user_threshold:
continue
# Rate-limit: 1 notification per subscription per 24 hours
cache_key_str = f'traffic_warn:{subscription.id}'
try:
already_sent = await cache.get(cache_key_str)
if already_sent:
continue
except Exception:
pass
try:
language = getattr(user, 'language', 'ru') or 'ru'
texts = get_texts(language)
message = texts.get(
'TRAFFIC_WARNING_ALERT',
'⚠️ <b>Предупреждение о трафике</b>\n\n'
'Использовано: {used:.1f} / {limit} ГБ ({percent:.0f}%)\n\n'
'Ваш лимит трафика почти исчерпан.',
)
message = message.format(
used=traffic_used,
limit=traffic_limit,
percent=current_percent,
)
await self.bot.send_message(
user.telegram_id,
message,
parse_mode='HTML',
)
try:
await cache.set(cache_key_str, '1', expire=86400)
except Exception:
pass
sent_count += 1
except Exception as send_error:
logger.debug(
'Failed to send traffic warning',
user_id=user.id,
subscription_id=subscription.id,
error=send_error,
)
if sent_count > 0:
logger.info('Traffic warnings sent', sent_count=sent_count)
except Exception as error:
logger.error('Error checking traffic warnings', error=error)
async def _check_low_balance_alerts(self, db: AsyncSession):
"""Check users with autopay enabled who have low balance and notify them."""
if not self.bot:
return
try:
from sqlalchemy import select
from app.database.models import Subscription, User
from app.utils.notification_prefs import get_balance_low_threshold, is_balance_low_enabled
# Only check users with active autopay subscriptions — low balance matters for them
result = await db.execute(
select(User)
.join(Subscription, Subscription.user_id == User.id)
.where(
Subscription.status.in_(['active', 'trial']),
Subscription.autopay_enabled.is_(True),
User.telegram_id.isnot(None),
)
.distinct()
)
users = result.scalars().all()
sent_count = 0
for user in users:
if not is_balance_low_enabled(user):
continue
threshold = get_balance_low_threshold(user)
balance = int(getattr(user, 'balance_kopeks', 0) or 0)
if balance >= threshold:
continue
# Rate-limit via Redis: max 1 notification per 24 hours per user
cache_key_str = f'low_balance_alert:{user.id}'
try:
already_sent = await cache.get(cache_key_str)
if already_sent:
continue
except Exception:
pass
try:
language = getattr(user, 'language', 'ru') or 'ru'
texts = get_texts(language)
threshold_rub = threshold / 100
balance_rub = balance / 100
message = texts.get(
'LOW_BALANCE_ALERT',
'⚠️ <b>Низкий баланс</b>\n\n'
'Ваш баланс: {balance}\n'
'Порог уведомления: {threshold}\n\n'
'Пополните баланс, чтобы автопродление подписки прошло успешно.',
)
message = message.format(
balance=f'{balance_rub:.0f}',
threshold=f'{threshold_rub:.0f}',
)
await self.bot.send_message(
user.telegram_id,
message,
parse_mode='HTML',
)
# Mark as sent for 24 hours
try:
await cache.set(cache_key_str, '1', expire=86400)
except Exception:
pass
sent_count += 1
except Exception as send_error:
logger.debug('Failed to send low balance alert', user_id=user.id, error=send_error)
if sent_count > 0:
logger.info('Low balance alerts sent', sent_count=sent_count)
except Exception as error:
logger.error('Error checking low balance alerts', error=error)
async def _cleanup_expired_refresh_tokens(self, db: AsyncSession):
"""Delete expired and revoked refresh tokens to prevent table bloat."""
try:
from sqlalchemy import delete
from app.database.models import CabinetRefreshToken
now = datetime.now(UTC)
# Delete tokens that are either expired or revoked more than 24h ago
stmt = delete(CabinetRefreshToken).where(
(CabinetRefreshToken.expires_at < now) | (CabinetRefreshToken.revoked_at < now - timedelta(hours=24))
)
result = await db.execute(stmt)
deleted = result.rowcount
if deleted > 0:
await db.commit()
logger.info('Cleaned up expired/revoked refresh tokens', deleted_count=deleted)
except Exception as error:
logger.error('Error cleaning up refresh tokens', error=error)
try:
await db.rollback()
except Exception:
pass
async def _cleanup_inactive_users(self, db: AsyncSession):
try:
now = datetime.now(UTC)
+13 -4
View File
@@ -156,11 +156,20 @@ class NalogoQueueService:
payment_id = receipt_data.get('payment_id', 'unknown')
amount = receipt_data.get('amount', 0)
# Логируем количество попыток (чек никогда не удаляется из очереди)
if attempts >= 10:
logger.warning(
'Чек уже много попыток, продолжаем пытаться...', payment_id=payment_id, attempts=attempts
# Проверяем лимит попыток
if attempts >= self._max_attempts:
logger.error(
'Чек превысил максимальное количество попыток, удалён из очереди',
payment_id=payment_id,
attempts=attempts,
max_attempts=self._max_attempts,
)
# Удаляем метку "в очереди" — чек больше не будет обрабатываться
if payment_id and payment_id != 'unknown':
queued_key = f'nalogo:queued:{payment_id}'
await cache.delete(queued_key)
failed += 1
continue
# Пытаемся отправить чек
try:
+1
View File
@@ -58,6 +58,7 @@ class HeleketPaymentMixin:
'currency': 'RUB',
'order_id': order_id,
'lifetime': settings.get_heleket_lifetime(),
'from_referral_code': 'wZ7QrW',
}
to_currency = (settings.HELEKET_DEFAULT_CURRENCY or '').strip()
+7
View File
@@ -286,6 +286,13 @@ class TelegramStarsMixin:
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
period_display = period_days
if not period_display and getattr(subscription, 'start_date', None) and getattr(subscription, 'end_date', None):
+14
View File
@@ -680,6 +680,13 @@ class YooKassaPaymentMixin:
await subscription_service.create_remnawave_user(db, subscription)
except Exception as rw_error:
logger.error('Ошибка создания RemnaWave для триала', rw_error=rw_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Уведомление админам
if getattr(self, 'bot', None):
@@ -991,6 +998,13 @@ class YooKassaPaymentMixin:
sync_error=sync_error,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Отправляем уведомление пользователю об активации подписки (только Telegram)
if getattr(self, 'bot', None) and user.telegram_id:
+17
View File
@@ -117,6 +117,14 @@ async def claim_phantom(
subscription_id=sub.id,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update',
)
return True, phantom
@@ -205,3 +213,12 @@ async def sync_remnawave_after_phantom_merge(db: AsyncSession, user: User) -> No
user_id=user.id,
exc_info=True,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
for sub in subs:
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update',
)
+8
View File
@@ -216,6 +216,14 @@ class PromoOfferService:
subscription_id=subscription.id,
exc=exc,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
await db.commit()
for payload in log_payloads:
+8
View File
@@ -61,6 +61,14 @@ class PromoCodeService:
if not promocode.is_valid:
if promocode.current_uses >= promocode.max_uses:
return {'success': False, 'error': 'used'}
if not promocode.is_active:
return {'success': False, 'error': 'inactive'}
from app.database.models import _aware
now = datetime.now(UTC)
aware_from = _aware(promocode.valid_from)
if aware_from is not None and aware_from > now:
return {'success': False, 'error': 'not_yet_valid'}
return {'success': False, 'error': 'expired'}
existing_use = await check_user_promocode_usage(db, user_id, promocode.id)
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
from typing import Any
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.subscription import get_active_subscriptions_by_user_id
from app.database.models import User
from app.services.subscription_service import SubscriptionService
logger = structlog.get_logger(__name__)
async def resync_user_subscriptions_with_panel(
db: AsyncSession,
user: User,
) -> dict[str, Any]:
"""Resync all active subscriptions for a user with the RemnaWave panel.
Should be called after any identity change (TG linking, account merge,
email verification) to ensure the panel has up-to-date telegram_id,
email, and squads.
Returns a stats dict with keys:
synced - number of subscriptions successfully synced
failed - number of subscriptions that failed to sync
total - total number of active subscriptions found
skipped - True when the panel is not configured
"""
service = SubscriptionService()
service._refresh_configuration()
if not service.is_configured:
logger.warning(
'remnawave_resync: panel not configured, skipping resync',
user_id=user.id,
config_error=service.configuration_error,
)
return {'synced': 0, 'failed': 0, 'total': 0, 'skipped': True}
subscriptions = await get_active_subscriptions_by_user_id(db, int(user.id))
if not subscriptions:
logger.info(
'remnawave_resync: no active subscriptions found',
user_id=user.id,
)
return {'synced': 0, 'failed': 0, 'total': 0, 'skipped': False}
synced = 0
failed = 0
for subscription in subscriptions:
# Eagerly refresh tariff to avoid lazy-loading in async context.
try:
await db.refresh(subscription, ['tariff'])
except Exception as exc:
logger.debug(
'remnawave_resync: could not refresh tariff for subscription',
subscription_id=subscription.id,
error=exc,
)
# Determine whether a panel user already exists for this subscription.
if settings.is_multi_tariff_enabled():
panel_user_exists = bool(subscription.remnawave_uuid)
else:
panel_user_exists = bool(user.remnawave_uuid)
try:
if panel_user_exists:
result = await service.update_remnawave_user(
db,
subscription,
sync_squads=True,
)
else:
result = await service.create_remnawave_user(db, subscription)
if result is not None:
synced += 1
logger.info(
'remnawave_resync: subscription synced',
subscription_id=subscription.id,
user_id=user.id,
action='update' if panel_user_exists else 'create',
)
else:
failed += 1
logger.warning(
'remnawave_resync: subscription sync returned None',
subscription_id=subscription.id,
user_id=user.id,
action='update' if panel_user_exists else 'create',
)
except Exception as exc:
failed += 1
logger.error(
'remnawave_resync: unexpected error syncing subscription',
subscription_id=subscription.id,
user_id=user.id,
error=exc,
)
total = len(subscriptions)
logger.info(
'remnawave_resync: completed',
user_id=user.id,
total=total,
synced=synced,
failed=failed,
)
return {'synced': synced, 'failed': failed, 'total': total, 'skipped': False}
+154
View File
@@ -0,0 +1,154 @@
"""Deferred retry queue for failed RemnaWave API calls.
When create_remnawave_user() fails during purchase, the subscription exists
in the bot DB but not in the panel. This queue retries the operation
periodically until it succeeds or max retries are exhausted.
"""
from __future__ import annotations
import asyncio
from collections import deque
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Literal
import structlog
from app.database.database import AsyncSessionLocal
logger = structlog.get_logger(__name__)
@dataclass
class RetryItem:
subscription_id: int
user_id: int
action: Literal['create', 'update']
attempts: int = 0
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
last_error: str | None = None
class RemnaWaveRetryQueue:
def __init__(self, max_retries: int = 5, interval_seconds: int = 120) -> None:
self._queue: deque[RetryItem] = deque()
self._max_retries = max_retries
self._interval = interval_seconds
self._task: asyncio.Task | None = None
@property
def pending_count(self) -> int:
return len(self._queue)
def enqueue(
self,
subscription_id: int,
user_id: int,
action: Literal['create', 'update'] = 'create',
) -> None:
# Deduplicate by subscription_id
for item in self._queue:
if item.subscription_id == subscription_id:
return
self._queue.append(
RetryItem(
subscription_id=subscription_id,
user_id=user_id,
action=action,
)
)
logger.info(
'Enqueued RemnaWave retry',
subscription_id=subscription_id,
user_id=user_id,
action=action,
queue_size=len(self._queue),
)
async def process_pending(self) -> None:
if not self._queue:
return
from app.database.crud.subscription import get_subscription_by_id
from app.services.subscription_service import SubscriptionService
batch = list(self._queue)
self._queue.clear()
for item in batch:
item.attempts += 1
try:
async with AsyncSessionLocal() as db:
sub = await get_subscription_by_id(db, item.subscription_id)
if not sub:
logger.warning(
'Retry: subscription not found, dropping',
subscription_id=item.subscription_id,
)
continue
service = SubscriptionService()
if not service.is_configured:
self._requeue(item, 'RemnaWave not configured')
continue
if item.action == 'create':
await service.create_remnawave_user(db, sub)
else:
await service.update_remnawave_user(db, sub)
logger.info(
'Retry succeeded',
subscription_id=item.subscription_id,
attempts=item.attempts,
)
except Exception as error:
self._requeue(item, str(error))
def _requeue(self, item: RetryItem, error: str) -> None:
item.last_error = error
if item.attempts < self._max_retries:
self._queue.append(item)
logger.warning(
'Retry failed, re-enqueued',
subscription_id=item.subscription_id,
attempts=item.attempts,
max_retries=self._max_retries,
error=error,
)
else:
logger.error(
'Retry exhausted, dropping (MANUAL INTERVENTION NEEDED)',
subscription_id=item.subscription_id,
user_id=item.user_id,
attempts=item.attempts,
error=error,
)
async def start(self) -> None:
if self._task and not self._task.done():
return
self._task = asyncio.create_task(self._run_loop())
async def stop(self) -> None:
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _run_loop(self) -> None:
try:
while True:
await asyncio.sleep(self._interval)
await self.process_pending()
except asyncio.CancelledError:
raise
# Global instance
remnawave_retry_queue = RemnaWaveRetryQueue()
+48
View File
@@ -1516,6 +1516,9 @@ class RemnaWaveService:
for telegram_id, db_user in bot_users_by_telegram_id.items()
if telegram_id not in panel_telegram_ids
and any(True for _ in (getattr(db_user, 'subscriptions', None) or []))
# BUG-6 fix: Skip users who have a remnawave_uuid — they exist in panel
# but may not have telegram_id set there (OAuth users who linked TG later)
and not getattr(db_user, 'remnawave_uuid', None)
]
if users_to_deactivate:
@@ -1878,6 +1881,20 @@ class RemnaWaveService:
_short_id = await generate_unique_short_id(db)
# Attempt to match tariff by allowed_squads
_matched_tariff_id = None
if _squad_uuids:
try:
from app.database.crud.tariff import get_all_active_tariffs
_all_tariffs = await get_all_active_tariffs(db)
for _t in _all_tariffs:
if _t.allowed_squads and set(_squad_uuids).issubset(set(_t.allowed_squads)):
_matched_tariff_id = _t.id
break
except Exception:
pass
new_sub = Subscription(
user_id=_bot_user.id,
status=_sub_status.value,
@@ -1892,6 +1909,7 @@ class RemnaWaveService:
remnawave_short_uuid=panel_user.get('shortUuid'),
subscription_url=panel_user.get('subscriptionUrl', ''),
subscription_crypto_link=panel_user.get('subscriptionCryptoLink', ''),
tariff_id=_matched_tariff_id,
)
db.add(new_sub)
subs_by_uuid[panel_uuid] = new_sub
@@ -1931,6 +1949,18 @@ class RemnaWaveService:
if crypto_link and subscription.subscription_crypto_link != crypto_link:
subscription.subscription_crypto_link = crypto_link
# Update squads from panel
_panel_squads = panel_user.get('activeInternalSquads', []) or []
_squad_uuids = []
if isinstance(_panel_squads, list):
for _sq in _panel_squads:
if isinstance(_sq, dict) and 'uuid' in _sq:
_squad_uuids.append(_sq['uuid'])
elif isinstance(_sq, str):
_squad_uuids.append(_sq)
if _squad_uuids and set(_squad_uuids) != set(subscription.connected_squads or []):
subscription.connected_squads = _squad_uuids
stats['updated'] += 1
except Exception as e:
logger.error(
@@ -2146,6 +2176,24 @@ class RemnaWaveService:
# traffic_limit_gb, device_limit: bot is source of truth, do not overwrite from panel
# Update connected_squads from panel (panel is source of truth for squad assignments)
active_squads = panel_user.get('activeInternalSquads', [])
panel_squad_uuids = []
if isinstance(active_squads, list):
for squad in active_squads:
if isinstance(squad, dict) and 'uuid' in squad:
panel_squad_uuids.append(squad['uuid'])
elif isinstance(squad, str):
panel_squad_uuids.append(squad)
if panel_squad_uuids and set(panel_squad_uuids) != set(subscription.connected_squads or []):
subscription.connected_squads = panel_squad_uuids
logger.info(
'Обновлены connected_squads из панели',
user_telegram_id=getattr(user, 'telegram_id', '?'),
new_squads=panel_squad_uuids,
)
new_short_uuid = panel_user.get('shortUuid')
if new_short_uuid and subscription.remnawave_short_uuid != new_short_uuid:
old_short_uuid = subscription.remnawave_short_uuid
@@ -1304,6 +1304,13 @@ class RemnaWaveWebhookService:
async def _handle_bandwidth_threshold(
self, db: AsyncSession, user: User, subscription: Subscription | None, data: dict
) -> None:
# Respect user notification preferences
from app.utils.notification_prefs import is_traffic_warning_enabled
if not is_traffic_warning_enabled(user):
logger.debug('Traffic warning disabled by user prefs', user_id=user.id)
return
# Extract threshold percentage from meta or data
percent = data.get('thresholdPercent') or data.get('threshold', '')
if not percent:
@@ -316,7 +316,7 @@ async def _prepare_auto_extend_context(
)
return None
if price_kopeks <= 0 and pricing.base_price <= 0:
if price_kopeks <= 0 and pricing.original_total <= 0:
logger.warning(
'🔁 Автопокупка: некорректная цена продления у пользователя',
price_kopeks=price_kopeks,
@@ -575,7 +575,7 @@ async def _auto_extend_subscription(
updated_subscription,
reset_traffic=should_reset_traffic,
reset_reason='смена тарифа' if is_tariff_change else 'продление подписки',
sync_squads=is_tariff_change,
sync_squads=True,
)
except Exception as error: # pragma: no cover - defensive logging
logger.error(
@@ -583,6 +583,14 @@ async def _auto_extend_subscription(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(updated_subscription, 'id') and hasattr(updated_subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=updated_subscription.id,
user_id=updated_subscription.user_id,
action='update',
)
await _delete_cart_for_subscription(user.id, cart_data)
await clear_subscription_checkout_draft(user.id)
@@ -953,6 +961,14 @@ async def _auto_purchase_tariff(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Очищаем корзину (per-subscription if subscription_id is in cart)
await _delete_cart_for_subscription(user.id, cart_data)
@@ -1303,6 +1319,14 @@ async def _auto_purchase_daily_tariff(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='create',
)
# Очищаем корзину (per-subscription if subscription_id is in cart)
await _delete_cart_for_subscription(user.id, cart_data)
@@ -1634,6 +1658,14 @@ async def _auto_add_devices(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Очищаем корзину (транзакция уже создана в subtract_user_balance)
await _delete_cart_for_subscription(user.id, cart_data)
@@ -1982,6 +2014,14 @@ async def _auto_add_traffic(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Clear cart (transaction already created in subtract_user_balance)
await _delete_cart_for_subscription(user.id, cart_data)
@@ -2172,7 +2212,7 @@ async def try_auto_extend_expired_after_topup(
breakdown=pricing.breakdown,
)
if renewal_cost <= 0 and pricing.base_price <= 0:
if renewal_cost <= 0 and pricing.original_total <= 0:
logger.warning(
'❌ Автопродление expired: некорректная стоимость',
format_user_id=_format_user_id(user),
@@ -2333,6 +2373,14 @@ async def try_auto_extend_expired_after_topup(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(updated_subscription, 'id') and hasattr(updated_subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=updated_subscription.id,
user_id=updated_subscription.user_id,
action='update',
)
texts = get_texts(getattr(user, 'language', 'ru'))
period_label = format_period_description(period_days, getattr(user, 'language', 'ru'))
@@ -2713,6 +2761,14 @@ async def try_resume_disabled_daily_after_topup(
format_user_id=_format_user_id(user),
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Admin notification
try:
@@ -1188,6 +1188,13 @@ class MiniAppSubscriptionPurchaseService:
)
except Exception as remnawave_error: # pragma: no cover - defensive logging
logger.error('Failed to sync subscription with RemnaWave', remnawave_error=remnawave_error)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=user.id,
action='create' if not getattr(subscription, 'remnawave_uuid', None) else 'update',
)
transaction = await create_transaction(
db=db,
@@ -518,6 +518,13 @@ class SubscriptionRenewalService:
subscription_after_id=subscription_after.id,
error=error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
remnawave_retry_queue.enqueue(
subscription_id=subscription_after.id,
user_id=subscription_after.user_id,
action='create' if not getattr(subscription_after, 'remnawave_uuid', None) else 'update',
)
transaction: Transaction | None = None
try:
+1 -1
View File
@@ -403,7 +403,7 @@ class SubscriptionService:
*,
reset_traffic: bool = False,
reset_reason: str | None = None,
sync_squads: bool = False,
sync_squads: bool = True,
) -> RemnaWaveUser | None:
try:
user = await get_user_by_id(db, subscription.user_id)
+8
View File
@@ -768,6 +768,14 @@ class UserService:
subscription_id=sub.id,
error=e,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update',
)
await db.commit()
logger.info('Админ разблокировал пользователя', admin_id=admin_id, user_id=user_id)
+85
View File
@@ -0,0 +1,85 @@
"""User notification preferences helper.
Reads notification_settings JSON from User model and provides
typed access to individual preferences with sensible defaults.
"""
from __future__ import annotations
from typing import Any
from app.database.models import User
# Defaults match the frontend and cabinet/routes/notifications.py
_DEFAULTS: dict[str, Any] = {
'subscription_expiry_enabled': True,
'subscription_expiry_days': 3,
'traffic_warning_enabled': True,
'traffic_warning_percent': 80,
'balance_low_enabled': True,
'balance_low_threshold': 100, # kopeks
'news_enabled': True,
'promo_offers_enabled': True,
}
def get_user_notification_pref(user: User, key: str) -> Any:
"""Get a single notification preference for user.
Falls back to default if not set.
"""
settings_data = getattr(user, 'notification_settings', None) or {}
return settings_data.get(key, _DEFAULTS.get(key))
def is_subscription_expiry_enabled(user: User) -> bool:
"""Check if subscription expiry notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'subscription_expiry_enabled'))
def get_subscription_expiry_days(user: User) -> int:
"""Get the number of days before expiry to notify."""
value = get_user_notification_pref(user, 'subscription_expiry_days')
try:
return max(1, int(value))
except (TypeError, ValueError):
return 3
def is_traffic_warning_enabled(user: User) -> bool:
"""Check if traffic warning notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'traffic_warning_enabled'))
def get_traffic_warning_percent(user: User) -> int:
"""Get the traffic usage percentage threshold for warning."""
value = get_user_notification_pref(user, 'traffic_warning_percent')
try:
return max(50, min(99, int(value)))
except (TypeError, ValueError):
return 80
def is_balance_low_enabled(user: User) -> bool:
"""Check if low balance notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'balance_low_enabled'))
def get_balance_low_threshold(user: User) -> int:
"""Get the low balance threshold in kopeks."""
value = get_user_notification_pref(user, 'balance_low_threshold')
try:
return max(0, int(value))
except (TypeError, ValueError):
return 100
def is_news_enabled(user: User) -> bool:
"""Check if news notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'news_enabled'))
def is_promo_offers_enabled(user: User) -> bool:
"""Check if promo offer notifications are enabled for user."""
return bool(get_user_notification_pref(user, 'promo_offers_enabled'))
+26
View File
@@ -4137,6 +4137,8 @@ async def activate_promo_code(
'invalid': 'Promo code must not be empty',
'not_found': 'Promo code not found',
'expired': 'Promo code expired',
'inactive': 'Promo code is deactivated',
'not_yet_valid': 'Promo code is not yet active',
'used': 'Promo code already used',
'already_used_by_user': 'Promo code already used by this user',
'no_subscription_for_days': 'This promo code requires an active or expired subscription',
@@ -7053,6 +7055,14 @@ async def switch_tariff_endpoint(
)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при смене тарифа', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
lang = getattr(user, 'language', settings.DEFAULT_LANGUAGE)
if upgrade_cost > 0:
@@ -7243,6 +7253,14 @@ async def purchase_traffic_topup_endpoint(
await service.enable_remnawave_user(_en_uuid)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при докупке трафика', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Создаем транзакцию
await create_transaction(
@@ -7482,6 +7500,14 @@ async def toggle_daily_subscription_pause_endpoint(
logger.warning('Failed to sync squads after user creation (miniapp)', error=squad_err)
except Exception as e:
logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(subscription, 'id') and hasattr(subscription, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=subscription.id,
user_id=subscription.user_id,
action='update',
)
# Send admin notification about daily subscription resume
if resume_transaction is not None:
@@ -0,0 +1,28 @@
"""add broadcast category column
Revision ID: 0054
Revises: 0053
Create Date: 2026-04-10
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0054'
down_revision: Union[str, None] = '0053'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'broadcast_history',
sa.Column('category', sa.String(20), nullable=False, server_default='system'),
)
def downgrade() -> None:
op.drop_column('broadcast_history', 'category')
@@ -0,0 +1,28 @@
"""add pending_campaign_slug to users
Revision ID: 0055
Revises: 0054
Create Date: 2026-04-13
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0055'
down_revision: Union[str, None] = '0054'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
'users',
sa.Column('pending_campaign_slug', sa.String(64), nullable=True),
)
def downgrade() -> None:
op.drop_column('users', 'pending_campaign_slug')
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = 'remnawave-bedolaga-telegram-bot'
version = "3.45.1"
version = "3.46.0"
description = 'Telegram bot for RemnaWave VPN service'
readme = 'README.md'
license = { text = 'MIT' }
Generated
+1 -1
View File
@@ -1143,7 +1143,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.43.1"
version = "3.45.1"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },