Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd | ||
|
|
c65b608f0b | ||
|
|
cff8368b7c | ||
|
|
2f367bbd6b | ||
|
|
c3d6324ec6 | ||
|
|
4800a2da80 | ||
|
|
70a0cd44a1 | ||
|
|
545c9c7d07 | ||
|
|
cd60f496fa | ||
|
|
f883065bb9 | ||
|
|
b71bd71d8d | ||
|
|
0524d24b13 | ||
|
|
cdfd94c814 | ||
|
|
6513681125 | ||
|
|
f321fd04cf | ||
|
|
baede17adf | ||
|
|
17bf8720a3 | ||
|
|
280aced20e | ||
|
|
d672032201 | ||
|
|
e826c4309d | ||
|
|
9f525dc7da | ||
|
|
b880032b9b | ||
|
|
8f9484b6ec | ||
|
|
c3622d9c2b | ||
|
|
51ffbbfa1d | ||
|
|
394e8dcc6c | ||
|
|
5f3fc13c2b | ||
|
|
4d43c9cf0f | ||
|
|
c891122064 | ||
|
|
59d07314e2 | ||
|
|
c438672ced | ||
|
|
49c532e4db | ||
|
|
d50324d098 | ||
|
|
52893a0629 | ||
|
|
664cea447c | ||
|
|
5f50cfeaee | ||
|
|
bdade47758 | ||
|
|
a3406451cb | ||
|
|
9938671af9 | ||
|
|
1d57c9be21 | ||
|
|
fde0f1b135 | ||
|
|
67df164498 | ||
|
|
48e666f605 | ||
|
|
3cb15fa254 | ||
|
|
2100949470 | ||
|
|
c2a475b062 | ||
|
|
585cbe8fb6 | ||
|
|
a243ddcac8 | ||
|
|
357d7caf26 | ||
|
|
2713b3bae5 |
+20
-6
@@ -20,10 +20,23 @@ TERMS_OF_SERVICE_URL=https://example.com/tos #
|
||||
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
|
||||
START_COMMAND_DESCRIPTION= # Description of the /start command
|
||||
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
|
||||
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
|
||||
USER_HWID_DEVICE_LIMIT=0 # Default HWID/device limit for panel users (0 = unlimited)
|
||||
|
||||
# Required channel subscription
|
||||
REQUIRED_CHANNEL_ID= # Telegram channel ID (e.g. -1001234567890) the user must join
|
||||
REQUIRED_CHANNEL_LINK=https://t.me/your_channel # Optional: public link/invite button text opens
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
FREEKASSA_ENABLED=True # Turn on FreeKassa
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
|
||||
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
|
||||
@@ -32,6 +45,13 @@ YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com #
|
||||
YOOKASSA_VAT_CODE=1 # VAT code
|
||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||
|
||||
# FreeKassa Payment Gateway Configuration
|
||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||
FREEKASSA_API_KEY=your_api_key # API key for REST requests
|
||||
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
|
||||
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
|
||||
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
|
||||
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN= # API token for CryptoPay
|
||||
CRYPTOPAY_NETWORK=mainnet # Network (mainnet or testnet)
|
||||
@@ -43,12 +63,6 @@ TRIBUTE_API_KEY= #
|
||||
TRIBUTE_SKIP_NOTIFICATIONS=True # Skip renewal notifications for Tribute payments
|
||||
TRIBUTE_SKIP_CANCELLATION_NOTIFICATIONS=False # Skip cancellation notifications for Tribute payments
|
||||
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True # Turn on YOOKASSA
|
||||
STARS_ENABLED=True # Turn on STARS
|
||||
TRIBUTE_ENABLED=True # Turn on TRIBUTE
|
||||
CRYPTOPAY_ENABLED=True # Turn on CRYPTOPAY
|
||||
|
||||
# Subscription Options. Specify cost parameters or payment links here.
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
|
||||
@@ -9,8 +9,9 @@ on:
|
||||
- dev
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -26,18 +27,26 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: |
|
||||
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
@@ -56,4 +65,4 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Image digest
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
run: echo ${{ steps.meta.outputs.digest }}
|
||||
|
||||
@@ -9,6 +9,10 @@ on:
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
|
||||
env:
|
||||
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -27,6 +31,12 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU (для эмуляции arm64 на x86)
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -40,5 +50,7 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
${{ env.GHCR_IMAGE }}:latest
|
||||
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
|
||||
${{ env.DOCKERHUB_IMAGE }}:latest
|
||||
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
### Для пользователей:
|
||||
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
|
||||
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
|
||||
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
|
||||
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
|
||||
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
|
||||
- **Реферальная программа:** Пользователи могут приглашать друзей и получать за это бонусные дни подписки.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, CryptoPay, Telegram Stars и Tribute.
|
||||
- **Оплата:** Поддержка оплаты через YooKassa, FreeKassa (REST API), CryptoPay, Telegram Stars и Tribute.
|
||||
|
||||
### Для администраторов:
|
||||
- **Защищенная админ-панель:** Доступ только для администраторов, указанных в `ADMIN_IDS`.
|
||||
@@ -27,7 +28,7 @@
|
||||
- **Aiogram 3.x:** Асинхронный фреймворк для Telegram ботов.
|
||||
- **aiohttp:** Для запуска веб-сервера (вебхуки).
|
||||
- **SQLAlchemy 2.x & asyncpg:** Асинхронная работа с базой данных PostgreSQL.
|
||||
- **YooKassa, aiocryptopay:** SDK для интеграции с платежными системами.
|
||||
- **YooKassa, FreeKassa API, aiocryptopay:** Интеграции с платежными системами.
|
||||
- **Pydantic:** Для управления настройками из `.env` файла.
|
||||
- **Docker & Docker Compose:** Для контейнеризации и развертывания.
|
||||
|
||||
@@ -66,6 +67,9 @@
|
||||
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
|
||||
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
|
||||
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
|
||||
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
|
||||
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
|
||||
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -81,6 +85,13 @@
|
||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||
| `FREEKASSA_MERCHANT_ID` | ID вашего магазина в FreeKassa. |
|
||||
| `FREEKASSA_API_KEY` | API-ключ для запросов к FreeKassa REST API. |
|
||||
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
|
||||
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
|
||||
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
|
||||
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
|
||||
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
|
||||
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
|
||||
</details>
|
||||
@@ -106,6 +117,9 @@
|
||||
| `PANEL_WEBHOOK_SECRET`| Секретный ключ для проверки вебхуков от панели. |
|
||||
| `USER_SQUAD_UUIDS` | ID отрядов для новых пользователей. |
|
||||
| `USER_TRAFFIC_LIMIT_GB`| Лимит трафика в ГБ (0 - безлимит). |
|
||||
| `USER_HWID_DEVICE_LIMIT`| Лимит устройств (HWID) для новых пользователей (0 - безлимит). |
|
||||
|
||||
> Раздел "Мои устройства" становится доступен пользователям только при включении `MY_DEVICES_SECTION_ENABLED`. Значение лимита устройств при создании записей в панели берётся из `USER_HWID_DEVICE_LIMIT`.
|
||||
</gidetails>
|
||||
|
||||
<details>
|
||||
@@ -125,12 +139,13 @@
|
||||
Эта команда скачает образ и запустит сервис в фоновом режиме.
|
||||
|
||||
4. **Настройка вебхуков (Обязательно):**
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||
Вебхуки являются **обязательным** компонентом для работы бота, так как они используются для получения уведомлений от платежных систем (YooKassa, FreeKassa, CryptoPay, Tribute) и панели Remnawave.
|
||||
|
||||
Вам понадобится обратный прокси (например, Nginx) для обработки HTTPS-трафика и перенаправления запросов на контейнер с ботом.
|
||||
|
||||
**Пути для перенаправления:**
|
||||
- `https://<ваш_домен>/webhook/yookassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/yookassa`
|
||||
- `https://<ваш_домен>/webhook/freekassa` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/freekassa`
|
||||
- `https://<ваш_домен>/webhook/cryptopay` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/cryptopay`
|
||||
- `https://<ваш_домен>/webhook/tribute` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/tribute`
|
||||
- `https://<ваш_домен>/webhook/panel` → `http://remnawave-tg-shop:<WEB_SERVER_PORT>/webhook/panel`
|
||||
@@ -143,10 +158,14 @@
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
|
||||
|
||||
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
|
||||
|
||||
## 📁 Структура проекта
|
||||
|
||||
```
|
||||
|
||||
@@ -13,6 +13,7 @@ from bot.middlewares.i18n import I18nMiddleware, get_i18n_instance, JsonI18n
|
||||
from bot.middlewares.ban_check_middleware import BanCheckMiddleware
|
||||
from bot.middlewares.action_logger_middleware import ActionLoggerMiddleware
|
||||
from bot.middlewares.profile_sync import ProfileSyncMiddleware
|
||||
from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
|
||||
|
||||
def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) -> tuple[Dispatcher, Bot, Dict]:
|
||||
@@ -31,8 +32,8 @@ def build_dispatcher(settings: Settings, async_session_factory: sessionmaker) ->
|
||||
dp.update.outer_middleware(I18nMiddleware(i18n=i18n_instance, settings=settings))
|
||||
dp.update.outer_middleware(ProfileSyncMiddleware())
|
||||
dp.update.outer_middleware(BanCheckMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||
dp.update.outer_middleware(ChannelSubscriptionMiddleware(settings=settings, i18n_instance=i18n_instance))
|
||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings))
|
||||
|
||||
return dp, bot, {"i18n_instance": i18n_instance}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
from bot.services.panel_webhook_service import PanelWebhookService
|
||||
from bot.services.freekassa_service import FreeKassaService
|
||||
|
||||
|
||||
def build_core_services(
|
||||
@@ -36,6 +37,14 @@ def build_core_services(
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
freekassa_service = FreeKassaService(
|
||||
bot=bot,
|
||||
settings=settings,
|
||||
i18n=i18n,
|
||||
async_session_factory=async_session_factory,
|
||||
subscription_service=subscription_service,
|
||||
referral_service=referral_service,
|
||||
)
|
||||
tribute_service = TributeService(
|
||||
bot,
|
||||
settings,
|
||||
@@ -70,9 +79,9 @@ def build_core_services(
|
||||
"promo_code_service": promo_code_service,
|
||||
"stars_service": stars_service,
|
||||
"cryptopay_service": cryptopay_service,
|
||||
"freekassa_service": freekassa_service,
|
||||
"tribute_service": tribute_service,
|
||||
"panel_webhook_service": panel_webhook_service,
|
||||
"yookassa_service": yookassa_service,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ async def build_and_start_web_app(
|
||||
"referral_service",
|
||||
"panel_service",
|
||||
"stars_service",
|
||||
"freekassa_service",
|
||||
"cryptopay_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
@@ -50,6 +51,7 @@ async def build_and_start_web_app(
|
||||
from bot.services.tribute_service import tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import cryptopay_webhook_route
|
||||
from bot.services.panel_webhook_service import panel_webhook_route
|
||||
from bot.services.freekassa_service import freekassa_webhook_route
|
||||
|
||||
tribute_path = settings.tribute_webhook_path
|
||||
if tribute_path.startswith("/"):
|
||||
@@ -61,6 +63,11 @@ async def build_and_start_web_app(
|
||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||
|
||||
fk_path = settings.freekassa_webhook_path
|
||||
if fk_path.startswith("/"):
|
||||
app.router.add_post(fk_path, freekassa_webhook_route)
|
||||
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
|
||||
|
||||
# YooKassa webhook (register only when base URL present and path configured)
|
||||
yk_path = settings.yookassa_webhook_path
|
||||
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
|
||||
@@ -88,4 +95,3 @@ async def build_and_start_web_app(
|
||||
# Run until cancelled
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
|
||||
@@ -127,6 +127,126 @@ async def show_ad_card(callback: types.CallbackQuery, settings: Settings, i18n_d
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete:"))
|
||||
async def ads_delete_prompt(callback: types.CallbackQuery, settings: Settings, i18n_data: dict):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, _, camp_id_str, back_page_str = callback.data.split(":", 3)
|
||||
camp_id = int(camp_id_str)
|
||||
back_page = int(back_page_str)
|
||||
except Exception:
|
||||
await callback.answer(i18n.gettext(current_lang, "error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
from bot.keyboards.inline.admin_keyboards import get_confirmation_keyboard
|
||||
confirm_text = i18n.gettext(current_lang, "admin_ads_delete_confirm", id=camp_id)
|
||||
kb = get_confirmation_keyboard(
|
||||
yes_callback_data=f"admin_ads:delete_confirm:{camp_id}:{back_page}",
|
||||
no_callback_data=f"admin_ads:delete_cancel:{camp_id}:{back_page}",
|
||||
i18n_instance=i18n,
|
||||
lang=current_lang,
|
||||
)
|
||||
try:
|
||||
await callback.message.edit_text(confirm_text, reply_markup=kb)
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_cancel:"))
|
||||
async def ads_delete_cancel(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
# Return to the ad card view
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":", 3)
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3])
|
||||
except Exception:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
camp = await ad_dal.get_campaign_by_id(session, camp_id)
|
||||
if not camp:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
return
|
||||
try:
|
||||
stats = await ad_dal.get_campaign_stats(session, camp_id)
|
||||
except Exception:
|
||||
stats = {"starts": 0, "trials": 0, "payers": 0, "revenue": 0.0}
|
||||
text = _(
|
||||
"admin_ads_card",
|
||||
id=camp.ad_campaign_id,
|
||||
source=camp.source,
|
||||
start_param=camp.start_param,
|
||||
cost=f"{camp.cost:.2f}",
|
||||
active=_("csv_yes") if camp.is_active else _("csv_no"),
|
||||
starts=stats["starts"],
|
||||
trials=stats["trials"],
|
||||
payers=stats["payers"],
|
||||
revenue=f"{stats['revenue']:.2f}",
|
||||
)
|
||||
from bot.keyboards.inline.admin_keyboards import get_ad_card_keyboard
|
||||
reply_markup = get_ad_card_keyboard(i18n, current_lang, camp.ad_campaign_id, back_page)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup, parse_mode="HTML")
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_ads:delete_confirm:"))
|
||||
async def ads_delete_confirm(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Language error.", show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
parts = callback.data.split(":", 3)
|
||||
camp_id = int(parts[2])
|
||||
back_page = int(parts[3])
|
||||
except Exception:
|
||||
await callback.answer(_("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
existed = await ad_dal.delete_campaign(session, camp_id)
|
||||
if not existed:
|
||||
await callback.answer(_("admin_ads_not_found", default="Кампания не найдена."), show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
|
||||
# After delete, show list page (may shift due to fewer items)
|
||||
totals = await ad_dal.get_totals(session)
|
||||
overview = _(
|
||||
"admin_ads_overview",
|
||||
revenue=f"{totals.get('revenue', 0.0):.2f}",
|
||||
cost=f"{totals.get('cost', 0.0):.2f}",
|
||||
)
|
||||
total_count = await ad_dal.count_campaigns(session)
|
||||
total_pages = max(1, (total_count + PAGE_SIZE - 1) // PAGE_SIZE)
|
||||
page = max(0, min(back_page, total_pages - 1))
|
||||
campaigns = await ad_dal.list_campaigns_paged(session, page=page, page_size=PAGE_SIZE)
|
||||
text = overview + "\n\n" + _("admin_ads_header")
|
||||
from bot.keyboards.inline.admin_keyboards import get_ads_list_keyboard
|
||||
reply_markup = get_ads_list_keyboard(i18n, current_lang, campaigns, page, total_pages)
|
||||
try:
|
||||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
except Exception:
|
||||
await callback.answer(_("admin_ads_deleted_success"), show_alert=True)
|
||||
@router.callback_query(F.data == "admin_action:ads_create")
|
||||
async def ads_create_start(callback: types.CallbackQuery, state: FSMContext, settings: Settings, i18n_data: dict):
|
||||
from bot.states.admin_states import AdminStates
|
||||
|
||||
@@ -334,11 +334,18 @@ async def confirm_broadcast_callback_handler(
|
||||
await session.rollback()
|
||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||
|
||||
# Get queue stats for detailed report
|
||||
# Prepare queue stats presentation
|
||||
queue_stats = queue_manager.get_queue_stats()
|
||||
|
||||
result_message = (
|
||||
_(
|
||||
back_keyboard = get_back_to_admin_panel_keyboard(current_lang, i18n)
|
||||
initial_user_failed = queue_stats.get("user_failed_messages", 0)
|
||||
initial_group_failed = queue_stats.get("group_failed_messages", 0)
|
||||
|
||||
def build_queue_status(stats: dict) -> str:
|
||||
dynamic_failed = max(
|
||||
0, stats.get("user_failed_messages", 0) - initial_user_failed
|
||||
) + max(0, stats.get("group_failed_messages", 0) - initial_group_failed)
|
||||
total_failed = failed_count + dynamic_failed
|
||||
return _(
|
||||
"broadcast_queue_result",
|
||||
default=(
|
||||
"🚀 Рассылка поставлена в очередь!\n"
|
||||
@@ -350,16 +357,64 @@ async def confirm_broadcast_callback_handler(
|
||||
"ℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram."
|
||||
),
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count,
|
||||
user_queue_size=queue_stats["user_queue_size"],
|
||||
group_queue_size=queue_stats["group_queue_size"],
|
||||
failed_count=total_failed,
|
||||
user_queue_size=stats["user_queue_size"],
|
||||
group_queue_size=stats["group_queue_size"],
|
||||
)
|
||||
)
|
||||
await callback.message.answer(
|
||||
|
||||
result_message = build_queue_status(queue_stats)
|
||||
|
||||
status_message = await callback.message.answer(
|
||||
result_message,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
|
||||
async def auto_update_queue_status() -> None:
|
||||
"""Refresh queue stats message twice per second via message edit."""
|
||||
last_text = result_message
|
||||
# Update for up to 2 minutes (240 iterations at 0.5s intervals)
|
||||
max_iterations = 240
|
||||
for _ in range(max_iterations):
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
stats = queue_manager.get_queue_stats()
|
||||
new_text = build_queue_status(stats)
|
||||
queues_drained = (
|
||||
stats["user_queue_size"] == 0
|
||||
and stats["group_queue_size"] == 0
|
||||
and not stats.get("user_queue_processing")
|
||||
and not stats.get("group_queue_processing")
|
||||
)
|
||||
|
||||
if new_text != last_text:
|
||||
try:
|
||||
await status_message.edit_text(
|
||||
new_text,
|
||||
reply_markup=back_keyboard,
|
||||
)
|
||||
last_text = new_text
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" in str(e):
|
||||
last_text = new_text
|
||||
else:
|
||||
logging.debug(
|
||||
"Broadcast queue auto-update stopped: %s", e
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
logging.debug(
|
||||
"Broadcast queue auto-update unexpected error: %s", e
|
||||
)
|
||||
break
|
||||
|
||||
if queues_drained:
|
||||
# Final refresh already attempted; exit loop.
|
||||
break
|
||||
else:
|
||||
logging.debug("Broadcast queue auto-update reached time limit.")
|
||||
|
||||
asyncio.create_task(auto_update_queue_status())
|
||||
|
||||
elif action == "cancel":
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled"),
|
||||
|
||||
@@ -98,8 +98,22 @@ async def admin_panel_actions_callback_handler(
|
||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_management":
|
||||
# This is deprecated, kept for compatibility
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_management_menu_handler(
|
||||
await admin_user_management_handlers.user_search_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "users_list" and len(action_parts) > 2:
|
||||
# Route to users list handler with page number
|
||||
from . import user_management as admin_user_management_handlers
|
||||
try:
|
||||
page = int(action_parts[2])
|
||||
await admin_user_management_handlers.users_list_handler(
|
||||
callback, i18n_data, settings, session, page)
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("Invalid page number", show_alert=True)
|
||||
elif action == "users_search_prompt":
|
||||
from . import user_management as admin_user_management_handlers
|
||||
await admin_user_management_handlers.user_search_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "view_banned":
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
||||
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
|
||||
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa'] else "❌"
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌"
|
||||
)
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
@@ -54,7 +54,8 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str) -> str:
|
||||
'yookassa': 'YooKassa',
|
||||
'tribute': 'Tribute',
|
||||
'telegram_stars': 'Telegram Stars',
|
||||
'cryptopay': 'CryptoPay'
|
||||
'cryptopay': 'CryptoPay',
|
||||
'freekassa': 'FreeKassa',
|
||||
}.get(payment.provider, payment.provider or 'Unknown')
|
||||
|
||||
return (
|
||||
@@ -246,4 +247,4 @@ async def export_payments_csv_handler(callback: types.CallbackQuery, i18n_data:
|
||||
@router.callback_query(F.data == "noop")
|
||||
async def noop_handler(callback: types.CallbackQuery):
|
||||
"""Handle no-op callback (for pagination display)."""
|
||||
await callback.answer()
|
||||
await callback.answer()
|
||||
|
||||
@@ -163,8 +163,7 @@ async def show_statistics_handler(callback: types.CallbackQuery,
|
||||
f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
|
||||
for payment in last_payments_models:
|
||||
status_emoji = "✅" if payment.status == 'succeeded' else (
|
||||
"⏳" if payment.status == 'pending'
|
||||
or payment.status == 'pending_yookassa' else "❌")
|
||||
"⏳" if payment.status in ['pending', 'pending_yookassa', 'pending_freekassa'] else "❌")
|
||||
|
||||
user_info = f"User {payment.user_id}"
|
||||
if payment.user and payment.user.username:
|
||||
|
||||
@@ -15,22 +15,71 @@ from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils import get_message_content, send_direct_message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from bot.utils.text_sanitizer import (
|
||||
sanitize_display_name,
|
||||
sanitize_username,
|
||||
username_for_display,
|
||||
)
|
||||
|
||||
router = Router(name="admin_user_management_router")
|
||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
|
||||
|
||||
async def user_management_menu_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
"""Display user management menu"""
|
||||
async def users_list_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession, page: int = 0):
|
||||
"""Display paginated list of all users"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error preparing user management.", show_alert=True)
|
||||
await callback.answer("Error preparing user list.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
try:
|
||||
# Get paginated users
|
||||
from bot.keyboards.inline.admin_keyboards import get_users_list_keyboard
|
||||
from db.dal import user_dal
|
||||
|
||||
users = await user_dal.get_all_users_paginated(session, page=page, page_size=15)
|
||||
total_users = await user_dal.count_all_users(session)
|
||||
total_pages = max(1, (total_users + 14) // 15)
|
||||
|
||||
# Format message
|
||||
header_text = _(
|
||||
"admin_users_list_header",
|
||||
default="👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||
current=page + 1,
|
||||
total=total_pages,
|
||||
total_users=total_users
|
||||
)
|
||||
|
||||
keyboard = get_users_list_keyboard(users, page, total_users, i18n, current_lang, page_size=15)
|
||||
|
||||
await callback.message.edit_text(
|
||||
header_text,
|
||||
reply_markup=keyboard,
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error displaying user list: {e}")
|
||||
await callback.answer("Ошибка отображения списка пользователей", show_alert=True)
|
||||
|
||||
|
||||
async def user_search_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
"""Display search prompt for user management"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error preparing search.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
@@ -89,8 +138,14 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
|
||||
text=_(key="admin_user_refresh_button", default="🔄 Обновить"),
|
||||
callback_data=f"user_action:refresh:{user_id}"
|
||||
)
|
||||
|
||||
# Row 4: Destructive action
|
||||
builder.button(
|
||||
text=_(key="admin_user_delete_button", default="❌ Удалить пользователя"),
|
||||
callback_data=f"user_action:delete_user:{user_id}"
|
||||
)
|
||||
|
||||
# Row 4: Back button
|
||||
# Row 5: Navigation
|
||||
builder.button(
|
||||
text=_(key="admin_user_search_new_button", default="🔍 Найти другого"),
|
||||
callback_data="admin_action:users_management"
|
||||
@@ -100,13 +155,14 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
|
||||
callback_data="admin_action:main"
|
||||
)
|
||||
|
||||
builder.adjust(2, 2, 2, 2)
|
||||
builder.adjust(2, 2, 2, 1, 2)
|
||||
return builder
|
||||
|
||||
|
||||
async def format_user_card(user: User, session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
i18n_instance, lang: str) -> str:
|
||||
i18n_instance, lang: str,
|
||||
referral_service: Optional[ReferralService] = None) -> str:
|
||||
"""Format user information as a detailed card"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
@@ -116,8 +172,16 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
|
||||
# User details
|
||||
na_value = _("admin_user_na_value", default="N/A")
|
||||
user_name = user.first_name or na_value
|
||||
username_display = f"@{user.username}" if user.username else na_value
|
||||
safe_first_name = sanitize_display_name(user.first_name) if user.first_name else None
|
||||
user_name = safe_first_name or na_value
|
||||
if user.username:
|
||||
sanitized_username = sanitize_username(user.username)
|
||||
if sanitized_username:
|
||||
username_display = f"@{sanitized_username}"
|
||||
else:
|
||||
username_display = username_for_display(user.username, with_at=False)
|
||||
else:
|
||||
username_display = na_value
|
||||
registration_date = user.registration_date.strftime('%Y-%m-%d %H:%M') if user.registration_date else na_value
|
||||
|
||||
card_parts.append(f"{_('admin_user_id_label', default='🆔 <b>ID:</b>')} {hcode(str(user.user_id))}")
|
||||
@@ -176,6 +240,31 @@ async def format_user_card(user: User, session: AsyncSession,
|
||||
had_subscriptions = await subscription_service.has_had_any_subscription(session, user.user_id)
|
||||
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал")
|
||||
card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}")
|
||||
|
||||
# Financial analytics (admin-only)
|
||||
try:
|
||||
from db.dal import payment_dal
|
||||
|
||||
# Total amount paid by this user
|
||||
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
|
||||
card_parts.append(f"{_('admin_user_total_paid_label', default='💰 <b>Всего оплачено:</b>')} {hcode(f'{total_paid:.2f} RUB')}")
|
||||
|
||||
# Total revenue from referrals
|
||||
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
|
||||
card_parts.append(f"{_('admin_user_referral_revenue_label', default='💸 <b>Доход по рефералам:</b>')} {hcode(f'{referral_revenue:.2f} RUB')}")
|
||||
except Exception as e_fin:
|
||||
logging.error(f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}")
|
||||
|
||||
# Referral stats
|
||||
if referral_service is not None:
|
||||
try:
|
||||
stats = await referral_service.get_referral_stats(session, user.user_id)
|
||||
invited_count = stats.get('invited_count', 0)
|
||||
purchased_count = stats.get('purchased_count', 0)
|
||||
card_parts.append(f"{_('admin_user_invited_friends_label', default='👥 <b>Приглашено друзей:</b>')} {hcode(str(invited_count))}")
|
||||
card_parts.append(f"{_('admin_user_ref_purchased_label', default='💳 <b>Купили подписку:</b>')} {hcode(str(purchased_count))}")
|
||||
except Exception as e_rs:
|
||||
logging.error(f"Failed to build referral stats for admin card {user.user_id}: {e_rs}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error getting user statistics for {user.user_id}: {e}")
|
||||
@@ -224,7 +313,8 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
||||
|
||||
# Format and send user card
|
||||
try:
|
||||
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang)
|
||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang)
|
||||
|
||||
await message.answer(
|
||||
@@ -283,6 +373,10 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
|
||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
||||
elif action == "refresh":
|
||||
await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang)
|
||||
elif action == "delete_user":
|
||||
await handle_delete_user_prompt(
|
||||
callback, state, user, settings, i18n, current_lang, session
|
||||
)
|
||||
else:
|
||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||
|
||||
@@ -482,7 +576,10 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
||||
await callback.answer("User not found", show_alert=True)
|
||||
return
|
||||
|
||||
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang)
|
||||
from config.settings import Settings as _Settings
|
||||
_settings = _Settings()
|
||||
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance)
|
||||
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang)
|
||||
|
||||
try:
|
||||
@@ -505,8 +602,217 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
||||
await callback.answer("Error refreshing user card", show_alert=True)
|
||||
|
||||
|
||||
# Destructive deletion flow
|
||||
async def handle_delete_user_prompt(callback: types.CallbackQuery, state: FSMContext,
|
||||
user: User, settings: Settings, i18n_instance,
|
||||
lang: str, session: AsyncSession):
|
||||
"""Trigger confirmation workflow for destructive deletion."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
|
||||
admin = callback.from_user
|
||||
admin_id = admin.id if admin else None
|
||||
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||
logging.warning(
|
||||
f"Unauthorized delete attempt by user {admin_id} targeting {user.user_id}."
|
||||
)
|
||||
await callback.answer(
|
||||
_(
|
||||
"admin_user_delete_not_allowed",
|
||||
default="❌ У вас нет прав для удаления пользователей.",
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
target_user_id=user.user_id,
|
||||
delete_initiator_id=admin_id,
|
||||
)
|
||||
await state.set_state(AdminStates.waiting_for_user_delete_confirmation)
|
||||
|
||||
prompt_text = _(
|
||||
"admin_user_delete_confirmation_prompt",
|
||||
default=(
|
||||
"⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\n"
|
||||
"Отправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\n"
|
||||
"Любой другой ответ отменит операцию."
|
||||
),
|
||||
user_id=hcode(str(user.user_id)),
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.answer(prompt_text, parse_mode="HTML")
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send delete confirmation prompt for user {user.user_id}: {e}"
|
||||
)
|
||||
await callback.message.reply(prompt_text, parse_mode="HTML")
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def _log_admin_user_deletion(
|
||||
session: AsyncSession,
|
||||
admin_id: int,
|
||||
admin_user: Optional[types.User],
|
||||
target_user_id: int,
|
||||
) -> None:
|
||||
"""Store audit log for successful deletion."""
|
||||
try:
|
||||
await message_log_dal.create_message_log_no_commit(
|
||||
session,
|
||||
{
|
||||
"user_id": admin_id,
|
||||
"telegram_username": admin_user.username if admin_user else None,
|
||||
"telegram_first_name": admin_user.first_name if admin_user else None,
|
||||
"event_type": "admin:user_deleted",
|
||||
"content": f"Admin {admin_id} deleted user {target_user_id}",
|
||||
"raw_update_preview": None,
|
||||
"is_admin_event": True,
|
||||
"target_user_id": target_user_id,
|
||||
"timestamp": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to log deletion audit for admin {admin_id} -> user {target_user_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
# Message handlers for state-based inputs
|
||||
|
||||
@router.message(AdminStates.waiting_for_user_delete_confirmation, F.text)
|
||||
async def process_delete_user_confirmation_handler(message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
"""Confirm and execute destructive user deletion."""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await message.reply("Language service error.")
|
||||
await state.clear()
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
admin = message.from_user
|
||||
admin_id = admin.id if admin else None
|
||||
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||
logging.warning(
|
||||
f"Unauthorized delete confirmation attempt by user {admin_id}."
|
||||
)
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_not_allowed",
|
||||
default="❌ У вас нет прав для удаления пользователей.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
target_user_id = data.get("target_user_id")
|
||||
if not target_user_id:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_state_missing",
|
||||
default="⚠️ Нет активной операции удаления. Начните заново.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
confirmation_input = message.text.strip() if message.text else ""
|
||||
if confirmation_input.lower() in {"/cancel", "cancel", "отмена"}:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_cancelled",
|
||||
default="Операция удаления отменена по запросу.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
if confirmation_input != str(target_user_id):
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_mismatch",
|
||||
default="⚠️ ID не совпадает. Удаление отменено.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
user_model = await user_dal.get_user_by_id(session, target_user_id)
|
||||
if not user_model:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_already_removed",
|
||||
default="ℹ️ Пользователь уже удален.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
if user_model.panel_user_uuid:
|
||||
panel_deleted = await panel_service.delete_user_from_panel(
|
||||
user_model.panel_user_uuid
|
||||
)
|
||||
if not panel_deleted:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_panel_error",
|
||||
default=(
|
||||
"❌ Не удалось удалить пользователя на панели. "
|
||||
"Операция прервана."
|
||||
),
|
||||
)
|
||||
)
|
||||
await session.rollback()
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
deleted = await user_dal.delete_user_and_relations(
|
||||
session, target_user_id
|
||||
)
|
||||
if not deleted:
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_already_removed",
|
||||
default="ℹ️ Пользователь уже удален.",
|
||||
)
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
await _log_admin_user_deletion(session, admin_id, admin, target_user_id)
|
||||
await session.commit()
|
||||
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_success",
|
||||
default="✅ Пользователь {user_id} удален из бота и панели.",
|
||||
user_id=hcode(str(target_user_id)),
|
||||
),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error deleting user {target_user_id}: {e}", exc_info=True)
|
||||
await session.rollback()
|
||||
await message.answer(
|
||||
_(
|
||||
"admin_user_delete_error",
|
||||
default="❌ Не удалось завершить удаление пользователя. Попробуйте позже.",
|
||||
)
|
||||
)
|
||||
finally:
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text)
|
||||
async def process_subscription_days_handler(message: types.Message, state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
@@ -556,7 +862,8 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
||||
# Show updated user card
|
||||
user = await user_dal.get_user_by_id(session, target_user_id)
|
||||
if user:
|
||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang)
|
||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang)
|
||||
|
||||
await message.answer(
|
||||
@@ -664,7 +971,8 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
async with PanelApiService(settings) as panel_service:
|
||||
subscription_service = SubscriptionService(settings, panel_service)
|
||||
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang)
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service)
|
||||
keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang)
|
||||
|
||||
await message.answer(
|
||||
@@ -865,9 +1173,9 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
||||
|
||||
@router.message(AdminStates.waiting_for_user_id_to_unban, F.text)
|
||||
async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
settings: Settings, i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
"""Process user unban input"""
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
@@ -931,4 +1239,59 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||
default="❌ Ошибка разблокировки пользователя"
|
||||
))
|
||||
|
||||
await state.clear()
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("admin_user_card_from_list:"))
|
||||
async def user_card_from_list_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings, bot: Bot,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
"""Display user card when clicked from user list"""
|
||||
try:
|
||||
parts = callback.data.split(":")
|
||||
user_id = int(parts[1])
|
||||
page = int(parts[2])
|
||||
except (IndexError, ValueError):
|
||||
await callback.answer("Invalid user data", show_alert=True)
|
||||
return
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
await callback.answer("Language service error", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
# Get user from database
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user:
|
||||
await callback.answer("User not found", show_alert=True)
|
||||
return
|
||||
|
||||
# Create keyboard with back to list button
|
||||
keyboard = get_user_card_keyboard(user_id, i18n, current_lang)
|
||||
keyboard.button(
|
||||
text=_("admin_user_back_to_list_button", default="⬅️ К списку"),
|
||||
callback_data=f"admin_action:users_list:{page}"
|
||||
)
|
||||
keyboard.adjust(2, 2, 2, 2, 1)
|
||||
|
||||
# Format user card
|
||||
try:
|
||||
from bot.services.referral_service import ReferralService
|
||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||
|
||||
await callback.message.edit_text(
|
||||
user_card_text,
|
||||
reply_markup=keyboard.as_markup(),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error displaying user card: {e}")
|
||||
await callback.answer("Error displaying user card", show_alert=True)
|
||||
|
||||
@@ -22,6 +22,7 @@ from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from bot.services.notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
|
||||
@@ -241,10 +242,12 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(
|
||||
session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
@@ -277,7 +280,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang, i18n, settings, config_link
|
||||
user_lang, i18n, settings, config_link, preserve_message=True
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
|
||||
+279
-23
@@ -7,16 +7,23 @@ from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional, Union
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timezone
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
|
||||
|
||||
from db.dal import user_dal
|
||||
from db.models import User
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_main_menu_inline_keyboard, get_language_selection_keyboard
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_main_menu_inline_keyboard,
|
||||
get_language_selection_keyboard,
|
||||
get_channel_subscription_keyboard,
|
||||
)
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
|
||||
|
||||
router = Router(name="user_start_router")
|
||||
|
||||
@@ -50,6 +57,7 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
pass
|
||||
return
|
||||
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
show_trial_button_in_menu = False
|
||||
@@ -115,6 +123,184 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
pass
|
||||
|
||||
|
||||
async def ensure_required_channel_subscription(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
settings: Settings,
|
||||
i18n: Optional[JsonI18n],
|
||||
current_lang: str,
|
||||
session: AsyncSession,
|
||||
db_user: Optional[User] = None) -> bool:
|
||||
"""
|
||||
Verify that the user is a member of the required channel (if configured).
|
||||
Returns True when access can proceed, False when user must subscribe first.
|
||||
"""
|
||||
required_channel_id = settings.REQUIRED_CHANNEL_ID
|
||||
if not required_channel_id:
|
||||
return True
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
user_id = event.from_user.id
|
||||
bot_instance: Optional[Bot] = getattr(event, "bot", None)
|
||||
if bot_instance is None and event.message:
|
||||
bot_instance = event.message.bot
|
||||
message_obj: Optional[types.Message] = event.message
|
||||
else:
|
||||
user_id = event.from_user.id
|
||||
bot_instance = event.bot if hasattr(event, "bot") else None
|
||||
message_obj = event
|
||||
|
||||
if bot_instance is None:
|
||||
logging.error(
|
||||
"Channel subscription check: bot instance missing for user %s.", user_id
|
||||
)
|
||||
return False
|
||||
|
||||
if user_id in settings.ADMIN_IDS:
|
||||
return True
|
||||
|
||||
if db_user is None:
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
except Exception as fetch_error:
|
||||
logging.error(
|
||||
"Channel subscription check: failed to fetch user %s: %s",
|
||||
user_id,
|
||||
fetch_error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not db_user:
|
||||
logging.warning(
|
||||
"Required channel check skipped because user %s is not persisted yet.",
|
||||
user_id,
|
||||
)
|
||||
return True
|
||||
|
||||
if (db_user.channel_subscription_verified
|
||||
and db_user.channel_subscription_verified_for
|
||||
== required_channel_id):
|
||||
return True
|
||||
|
||||
def translate(key: str, **kwargs) -> str:
|
||||
if i18n:
|
||||
return i18n.gettext(current_lang, key, **kwargs)
|
||||
return key
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
is_member = False
|
||||
status_value = None
|
||||
|
||||
try:
|
||||
member = await bot_instance.get_chat_member(required_channel_id, user_id)
|
||||
status = getattr(member, "status", None)
|
||||
status_value = getattr(status, "value", status)
|
||||
allowed_statuses = {"creator", "administrator", "member", "restricted"}
|
||||
if status_value in allowed_statuses:
|
||||
is_member = True
|
||||
except TelegramBadRequest as bad_request:
|
||||
logging.info(
|
||||
"Required channel check: user %s not subscribed (details: %s)",
|
||||
user_id,
|
||||
bad_request,
|
||||
)
|
||||
except TelegramForbiddenError as forbidden_error:
|
||||
logging.error(
|
||||
"Required channel check failed due to insufficient permissions: %s",
|
||||
forbidden_error,
|
||||
)
|
||||
error_text = translate("channel_subscription_check_failed")
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(error_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if message_obj:
|
||||
try:
|
||||
await message_obj.answer(error_text)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await event.answer(error_text)
|
||||
return False
|
||||
except TelegramAPIError as api_error:
|
||||
logging.error(
|
||||
"Required channel check failed for user %s: %s",
|
||||
user_id,
|
||||
api_error,
|
||||
exc_info=True,
|
||||
)
|
||||
error_text = translate("channel_subscription_check_failed")
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(error_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
if message_obj:
|
||||
try:
|
||||
await message_obj.answer(error_text)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await event.answer(error_text)
|
||||
return False
|
||||
|
||||
update_payload = {
|
||||
"channel_subscription_checked_at": now,
|
||||
"channel_subscription_verified_for": required_channel_id,
|
||||
"channel_subscription_verified": is_member,
|
||||
}
|
||||
try:
|
||||
await user_dal.update_user(session, user_id, update_payload)
|
||||
except Exception as update_error:
|
||||
logging.error(
|
||||
"Failed to persist channel verification result for user %s: %s",
|
||||
user_id,
|
||||
update_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if is_member:
|
||||
logging.info(
|
||||
"User %s confirmed as member of required channel %s (status=%s).",
|
||||
user_id,
|
||||
required_channel_id,
|
||||
status_value,
|
||||
)
|
||||
return True
|
||||
|
||||
keyboard = (get_channel_subscription_keyboard(
|
||||
current_lang, i18n, settings.REQUIRED_CHANNEL_LINK
|
||||
)
|
||||
if i18n else None)
|
||||
|
||||
prompt_text = translate("channel_subscription_required")
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
if keyboard and event.message:
|
||||
try:
|
||||
await event.message.edit_text(prompt_text, reply_markup=keyboard)
|
||||
except Exception as edit_error:
|
||||
logging.debug(
|
||||
"Failed to edit prompt message for user %s: %s",
|
||||
user_id,
|
||||
edit_error,
|
||||
)
|
||||
if keyboard is None and message_obj:
|
||||
try:
|
||||
await message_obj.answer(prompt_text)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.answer(prompt_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await event.answer(prompt_text, reply_markup=keyboard)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@router.message(CommandStart())
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||
@@ -152,13 +338,17 @@ async def start_command_handler(message: types.Message,
|
||||
ad_start_param = ad_param_match.group(1)
|
||||
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
|
||||
|
||||
sanitized_username = sanitize_username(user.username)
|
||||
sanitized_first_name = sanitize_display_name(user.first_name)
|
||||
sanitized_last_name = sanitize_display_name(user.last_name)
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
user_data_to_create = {
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"username": sanitized_username,
|
||||
"first_name": sanitized_first_name,
|
||||
"last_name": sanitized_last_name,
|
||||
"language_code": current_lang,
|
||||
"referred_by_id": referred_by_user_id,
|
||||
"registration_date": datetime.now(timezone.utc)
|
||||
@@ -177,8 +367,8 @@ async def start_command_handler(message: types.Message,
|
||||
notification_service = NotificationService(message.bot, settings, i18n)
|
||||
await notification_service.notify_new_user_registration(
|
||||
user_id=user_id,
|
||||
username=user.username,
|
||||
first_name=user.first_name,
|
||||
username=sanitized_username,
|
||||
first_name=sanitized_first_name,
|
||||
referred_by_id=referred_by_user_id
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -203,12 +393,12 @@ async def start_command_handler(message: types.Message,
|
||||
is_active_now = False
|
||||
if not is_active_now:
|
||||
update_payload["referred_by_id"] = referred_by_user_id
|
||||
if user.username != db_user.username:
|
||||
update_payload["username"] = user.username
|
||||
if user.first_name != db_user.first_name:
|
||||
update_payload["first_name"] = user.first_name
|
||||
if user.last_name != db_user.last_name:
|
||||
update_payload["last_name"] = user.last_name
|
||||
if sanitized_username != db_user.username:
|
||||
update_payload["username"] = sanitized_username
|
||||
if sanitized_first_name != db_user.first_name:
|
||||
update_payload["first_name"] = sanitized_first_name
|
||||
if sanitized_last_name != db_user.last_name:
|
||||
update_payload["last_name"] = sanitized_last_name
|
||||
|
||||
if update_payload:
|
||||
try:
|
||||
@@ -238,55 +428,60 @@ async def start_command_handler(message: types.Message,
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not await ensure_required_channel_subscription(message, settings, i18n,
|
||||
current_lang, session,
|
||||
db_user):
|
||||
return
|
||||
|
||||
# Send welcome message if not disabled
|
||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
|
||||
|
||||
# Auto-apply promo code if provided via start parameter
|
||||
if promo_code_to_apply:
|
||||
try:
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
promo_code_service = PromoCodeService(settings, subscription_service, message.bot, i18n)
|
||||
|
||||
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user_id, promo_code_to_apply, current_lang
|
||||
)
|
||||
|
||||
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(f"Auto-applied promo code '{promo_code_to_apply}' for user {user_id}")
|
||||
|
||||
|
||||
# Get updated subscription details
|
||||
active = await subscription_service.get_active_subscription_details(session, user_id)
|
||||
config_link = active.get("config_link") if active else None
|
||||
config_link = config_link or _("config_link_not_available")
|
||||
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
|
||||
|
||||
promo_success_text = _(
|
||||
"promo_code_applied_success_full",
|
||||
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
|
||||
config_link=config_link,
|
||||
)
|
||||
|
||||
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
await message.answer(
|
||||
promo_success_text,
|
||||
reply_markup=get_connect_and_main_keyboard(current_lang, i18n, settings, config_link),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
# Don't show main menu if promo was successfully applied
|
||||
return
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
|
||||
# Continue to show main menu if promo failed
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
await send_main_menu(message,
|
||||
settings,
|
||||
i18n_data,
|
||||
@@ -295,6 +490,57 @@ async def start_command_handler(message: types.Message,
|
||||
is_edit=False)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "channel_subscription:verify")
|
||||
async def verify_channel_subscription_callback(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, callback.from_user.id)
|
||||
|
||||
verified = await ensure_required_channel_subscription(
|
||||
callback, settings, i18n, current_lang, session, db_user)
|
||||
if not verified:
|
||||
return
|
||||
|
||||
if db_user and db_user.language_code:
|
||||
current_lang = db_user.language_code
|
||||
i18n_data["current_language"] = current_lang
|
||||
|
||||
if i18n:
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
else:
|
||||
_ = lambda key, **kwargs: key
|
||||
|
||||
if not settings.DISABLE_WELCOME_MESSAGE:
|
||||
welcome_text = _(key="welcome",
|
||||
user_name=hd.quote(callback.from_user.full_name))
|
||||
if callback.message:
|
||||
await callback.message.answer(welcome_text)
|
||||
else:
|
||||
fallback_bot: Optional[Bot] = getattr(callback, "bot", None)
|
||||
if fallback_bot:
|
||||
await fallback_bot.send_message(callback.from_user.id,
|
||||
welcome_text)
|
||||
|
||||
try:
|
||||
await callback.answer(_(key="channel_subscription_verified_success"),
|
||||
show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=bool(callback.message))
|
||||
|
||||
|
||||
@router.message(Command("language"))
|
||||
@router.callback_query(F.data == "main_action:language")
|
||||
async def language_command_handler(
|
||||
@@ -399,10 +645,13 @@ async def main_action_callback_handler(
|
||||
await user_subscription_handlers.display_subscription_options(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "my_subscription":
|
||||
|
||||
await user_subscription_handlers.my_subscription_command_handler(
|
||||
callback, i18n_data, settings, panel_service, subscription_service,
|
||||
session, bot)
|
||||
elif action == "my_devices":
|
||||
await user_subscription_handlers.my_devices_command_handler(
|
||||
callback, i18n_data, settings, panel_service, subscription_service,
|
||||
session, bot)
|
||||
elif action == "referral":
|
||||
await user_referral_handlers.referral_command_handler(
|
||||
callback, settings, i18n_data, referral_service, bot, session)
|
||||
@@ -422,6 +671,13 @@ async def main_action_callback_handler(
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
elif action == "back_to_main_keep":
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=False)
|
||||
else:
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(
|
||||
|
||||
@@ -12,6 +12,6 @@ router.include_router(payments.router)
|
||||
router.include_router(payment_methods.router)
|
||||
|
||||
# Re-export commonly used entrypoints for backward compatibility
|
||||
from .core import display_subscription_options, my_subscription_command_handler # noqa: E402,F401
|
||||
from .core import display_subscription_options, my_subscription_command_handler, my_devices_command_handler # noqa: E402,F401
|
||||
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ async def my_subscription_command_handler(
|
||||
# Build rows to prepend above the base "back" markup
|
||||
prepend_rows = []
|
||||
|
||||
# 1) Mini-app connect button on top if enabled
|
||||
# 1) Mini-app connect button on top if enabled, otherwise fall back to config link URL
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
@@ -163,6 +163,66 @@ async def my_subscription_command_handler(
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
])
|
||||
else:
|
||||
cfg_link_val = (active or {}).get("config_link")
|
||||
if cfg_link_val:
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=get_text("connect_button"),
|
||||
url=cfg_link_val,
|
||||
)
|
||||
])
|
||||
|
||||
if settings.MY_DEVICES_SECTION_ENABLED:
|
||||
max_devices_value = active.get("max_devices")
|
||||
max_devices_display = get_text("devices_unlimited_label")
|
||||
if max_devices_value not in (None, 0):
|
||||
try:
|
||||
max_devices_int = int(max_devices_value)
|
||||
if max_devices_int >= 0:
|
||||
max_devices_display = str(max_devices_int)
|
||||
except (TypeError, ValueError):
|
||||
max_devices_display = str(max_devices_value)
|
||||
current_devices_display = "?"
|
||||
user_uuid = active.get("user_id")
|
||||
devices_response = None
|
||||
if user_uuid:
|
||||
try:
|
||||
devices_response = await panel_service.get_user_devices(user_uuid)
|
||||
except Exception:
|
||||
logging.exception("Failed to load devices for user %s", user_uuid)
|
||||
if devices_response:
|
||||
devices_count: Optional[int] = None
|
||||
if isinstance(devices_response, dict):
|
||||
devices_list = devices_response.get("devices")
|
||||
if isinstance(devices_list, list):
|
||||
devices_count = len(devices_list)
|
||||
elif isinstance(devices_list, int):
|
||||
devices_count = devices_list
|
||||
else:
|
||||
try:
|
||||
devices_count = len(devices_list) # type: ignore[arg-type]
|
||||
except Exception:
|
||||
devices_count = None
|
||||
if devices_count is None:
|
||||
total_value = devices_response.get("total")
|
||||
if isinstance(total_value, int):
|
||||
devices_count = total_value
|
||||
elif isinstance(devices_response, list):
|
||||
devices_count = len(devices_response)
|
||||
if devices_count is not None:
|
||||
current_devices_display = str(devices_count)
|
||||
devices_button_text = get_text(
|
||||
"devices_button",
|
||||
current_devices=current_devices_display,
|
||||
max_devices=max_devices_display,
|
||||
)
|
||||
prepend_rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=devices_button_text,
|
||||
callback_data="main_action:my_devices",
|
||||
)
|
||||
])
|
||||
|
||||
# 2) Auto-renew toggle (if supported and not tribute)
|
||||
if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False):
|
||||
@@ -207,6 +267,161 @@ async def my_subscription_command_handler(
|
||||
await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:my_devices")
|
||||
async def my_devices_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot,
|
||||
):
|
||||
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||
|
||||
if not i18n or not target:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(get_text("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await target.answer(get_text("my_devices_feature_disabled"))
|
||||
return
|
||||
|
||||
# TODO: context?
|
||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||
if not active or not active.get("user_id"):
|
||||
message = get_text("subscription_not_active")
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(message, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await target.answer(message)
|
||||
return
|
||||
|
||||
devices = await panel_service.get_user_devices(active.get("user_id")) if active else None
|
||||
if not devices:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer(get_text("no_devices_found"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
await target.answer(get_text("no_devices_found"))
|
||||
return
|
||||
|
||||
max_devices_value = active.get("max_devices")
|
||||
max_devices_display = get_text("devices_unlimited_label")
|
||||
if max_devices_value not in (None, 0):
|
||||
try:
|
||||
max_devices_int = int(max_devices_value)
|
||||
if max_devices_int >= 0:
|
||||
max_devices_display = str(max_devices_int)
|
||||
except (TypeError, ValueError):
|
||||
max_devices_display = str(max_devices_value)
|
||||
|
||||
if not devices or not devices.get('devices') or len(devices.get('devices')) == 0:
|
||||
text = get_text("no_devices_details_found_message", max_devices=max_devices_display)
|
||||
else:
|
||||
devices_list = []
|
||||
current_devices = len(devices.get('devices') or [])
|
||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||
device_model = device.get('deviceModel') or None
|
||||
platform = device.get('platform') or None
|
||||
user_agent = device.get('userAgent') or None
|
||||
os_version = device.get('osVersion') or None
|
||||
created_at = device.get('createdAt')
|
||||
hwid = device.get('hwid')
|
||||
created_at_str = datetime.fromisoformat(created_at).strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
device_details = get_text("device_details", index=index, device_model=device_model, platform=platform, os_version=os_version, created_at_str=created_at_str, user_agent=user_agent, hwid=hwid)
|
||||
devices_list.append(device_details)
|
||||
|
||||
text = get_text("my_devices_details", devices="\n\n".join(devices_list), current_devices=current_devices, max_devices=max_devices_display)
|
||||
|
||||
base_markup = get_back_to_main_menu_markup(current_lang, i18n, callback_data="main_action:my_subscription")
|
||||
kb = base_markup.inline_keyboard
|
||||
|
||||
devices_kb = []
|
||||
for index, device in enumerate(devices.get('devices') or [], start=1):
|
||||
hwid = device.get('hwid')
|
||||
device_button_text = get_text("disconnect_device_button", hwid=hwid, index=index)
|
||||
|
||||
devices_kb.append([InlineKeyboardButton(text=device_button_text, callback_data=f"disconnect_device:{hwid}")])
|
||||
kb = devices_kb + kb
|
||||
markup = InlineKeyboardMarkup(inline_keyboard=kb)
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
try:
|
||||
await event.answer()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=markup)
|
||||
except Exception:
|
||||
await event.message.answer(text, reply_markup=markup)
|
||||
else:
|
||||
await target.answer(text, reply_markup=markup)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("disconnect_device:"))
|
||||
async def disconnect_device_handler(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
bot: Bot,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not settings.MY_DEVICES_SECTION_ENABLED:
|
||||
try:
|
||||
await callback.answer(get_text("my_devices_feature_disabled"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
try:
|
||||
_, hwid = callback.data.split(":", 1)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
active = await subscription_service.get_active_subscription_details(session, callback.from_user.id)
|
||||
if not active:
|
||||
await callback.answer(get_text("subscription_not_active"), show_alert=True)
|
||||
return
|
||||
|
||||
success = await panel_service.disconnect_device(active.get("user_id"), hwid)
|
||||
if not success:
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
await session.commit()
|
||||
try:
|
||||
await callback.answer(get_text("device_disconnected"))
|
||||
except Exception:
|
||||
pass
|
||||
await my_devices_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("toggle_autorenew:"))
|
||||
async def toggle_autorenew_handler(
|
||||
callback: types.CallbackQuery,
|
||||
@@ -350,5 +565,3 @@ async def connect_command_handler(
|
||||
):
|
||||
logging.info(f"User {message.from_user.id} used /connect command.")
|
||||
await my_subscription_command_handler(message, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,13 +59,15 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.button(text=_(key="admin_users_management_button"),
|
||||
callback_data="admin_action:users_management")
|
||||
callback_data="admin_action:users_list:0")
|
||||
builder.button(text=_(key="admin_users_search_button"),
|
||||
callback_data="admin_action:users_search_prompt")
|
||||
builder.button(text=_(key="admin_ban_management_section"),
|
||||
callback_data="admin_section:ban_management")
|
||||
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main")
|
||||
builder.adjust(2, 1)
|
||||
builder.adjust(2, 1, 1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -185,6 +187,9 @@ def get_ads_list_keyboard(
|
||||
def get_ad_card_keyboard(i18n_instance, lang: str, campaign_id: int, back_page: int) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
# Dangerous action: Delete campaign
|
||||
builder.button(text=_(key="admin_ads_delete_button", default="🗑 Удалить кампанию"),
|
||||
callback_data=f"admin_ads:delete:{campaign_id}:{back_page}")
|
||||
builder.button(text=_(key="back_to_ads_list_button", default="⬅️ К списку"),
|
||||
callback_data=f"admin_ads:page:{back_page}")
|
||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||
@@ -302,6 +307,68 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_users_list_keyboard(users: List[User], current_page: int,
|
||||
total_users: int, i18n_instance, lang: str,
|
||||
page_size: int = 15) -> InlineKeyboardMarkup:
|
||||
"""Generate keyboard for paginated user list"""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
# Add user buttons
|
||||
for user in users:
|
||||
user_display_parts = []
|
||||
if user.username:
|
||||
user_display_parts.append(f"@{user.username}")
|
||||
user_display_parts.append(f"ID: {user.user_id}")
|
||||
if user.first_name:
|
||||
user_display_parts.append(f"- {user.first_name}")
|
||||
|
||||
button_text = " ".join(user_display_parts)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"admin_user_card_from_list:{user.user_id}:{current_page}"
|
||||
)
|
||||
)
|
||||
|
||||
# Pagination buttons
|
||||
if total_users > page_size:
|
||||
total_pages = math.ceil(total_users / page_size)
|
||||
pagination_buttons = []
|
||||
if current_page > 0:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("prev_page_button"),
|
||||
callback_data=f"admin_action:users_list:{current_page - 1}"
|
||||
)
|
||||
)
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{current_page + 1}/{total_pages}",
|
||||
callback_data="stub_page_display"
|
||||
)
|
||||
)
|
||||
if current_page < total_pages - 1:
|
||||
pagination_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text=_("next_page_button"),
|
||||
callback_data=f"admin_action:users_list:{current_page + 1}"
|
||||
)
|
||||
)
|
||||
if pagination_buttons:
|
||||
builder.row(*pagination_buttons)
|
||||
|
||||
# Back button
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("back_to_user_management_button"),
|
||||
callback_data="admin_section:user_management"
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_user_card_keyboard(user_id: int,
|
||||
is_banned: bool,
|
||||
i18n_instance,
|
||||
|
||||
@@ -118,14 +118,17 @@ def get_payment_method_keyboard(months: int, price: float,
|
||||
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
if settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{months}:{stars_price}")
|
||||
if settings.TRIBUTE_ENABLED and tribute_url:
|
||||
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
||||
if settings.FREEKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_sbp_button"),
|
||||
callback_data=f"pay_fk:{months}:{price}")
|
||||
if settings.YOOKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{months}:{price}")
|
||||
if settings.TRIBUTE_ENABLED and tribute_url:
|
||||
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
||||
if settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{months}:{stars_price}")
|
||||
if settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{months}:{price}")
|
||||
@@ -135,17 +138,115 @@ def get_payment_method_keyboard(months: int, price: float,
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_payment_url_keyboard(payment_url: str, lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
def get_payment_url_keyboard(payment_url: str,
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
back_callback: Optional[str] = None,
|
||||
back_text_key: str = "back_to_main_menu_button"
|
||||
) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="pay_button"), url=payment_url)
|
||||
builder.button(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main")
|
||||
if back_callback:
|
||||
builder.button(text=_(key=back_text_key), callback_data=back_callback)
|
||||
else:
|
||||
builder.button(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main")
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_yk_autopay_choice_keyboard(
|
||||
months: int,
|
||||
price: float,
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
has_saved_cards: bool = True,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
price_str = str(price)
|
||||
if has_saved_cards:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_saved_card_button"),
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_payment_methods_button"),
|
||||
callback_data=f"subscribe_period:{months}",
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_yk_saved_cards_keyboard(
|
||||
cards: List[Tuple[str, str]],
|
||||
months: int,
|
||||
price: float,
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
page: int = 0,
|
||||
) -> InlineKeyboardMarkup:
|
||||
"""Paginated keyboard for selecting a saved YooKassa card."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
per_page = 5
|
||||
total = len(cards)
|
||||
start = page * per_page
|
||||
end = min(total, start + per_page)
|
||||
price_str = str(price)
|
||||
|
||||
for method_id, title in cards[start:end]:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=title,
|
||||
callback_data=f"pay_yk_use_saved:{months}:{price_str}:{method_id}",
|
||||
)
|
||||
)
|
||||
|
||||
nav_buttons: List[InlineKeyboardButton] = []
|
||||
if start > 0:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="⬅️",
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page-1}",
|
||||
)
|
||||
)
|
||||
if end < total:
|
||||
nav_buttons.append(
|
||||
InlineKeyboardButton(
|
||||
text="➡️",
|
||||
callback_data=f"pay_yk_saved_list:{months}:{price_str}:{page+1}",
|
||||
)
|
||||
)
|
||||
if nav_buttons:
|
||||
builder.row(*nav_buttons)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="yookassa_autopay_pay_new_card_button"),
|
||||
callback_data=f"pay_yk_new:{months}:{price_str}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="back_to_autopay_method_choice_button"),
|
||||
callback_data=f"pay_yk:{months}:{price_str}",
|
||||
)
|
||||
)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_referral_link_keyboard(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
@@ -159,11 +260,16 @@ def get_referral_link_keyboard(lang: str,
|
||||
|
||||
|
||||
def get_back_to_main_menu_markup(lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
i18n_instance,
|
||||
callback_data: Optional[str] = None) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main")
|
||||
if callback_data:
|
||||
builder.button(text=_(key="back_to_main_menu_button"),
|
||||
callback_data=callback_data)
|
||||
else:
|
||||
builder.button(text=_(key="back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main")
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@@ -185,11 +291,49 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_channel_subscription_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
channel_link: Optional[str],
|
||||
include_check_button: bool = True) -> Optional[InlineKeyboardMarkup]:
|
||||
"""
|
||||
Return keyboard with buttons to open the required channel and trigger a subscription re-check.
|
||||
"""
|
||||
if i18n_instance is None:
|
||||
return None
|
||||
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
has_buttons = False
|
||||
|
||||
if channel_link:
|
||||
builder.button(
|
||||
text=_(key="channel_subscription_join_button"),
|
||||
url=channel_link,
|
||||
)
|
||||
has_buttons = True
|
||||
|
||||
if include_check_button:
|
||||
builder.button(
|
||||
text=_(key="channel_subscription_verify_button"),
|
||||
callback_data="channel_subscription:verify",
|
||||
)
|
||||
has_buttons = True
|
||||
|
||||
if not has_buttons:
|
||||
return None
|
||||
|
||||
builder.adjust(1)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_connect_and_main_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
config_link: Optional[str]) -> InlineKeyboardMarkup:
|
||||
config_link: Optional[str],
|
||||
preserve_message: bool = False) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
@@ -213,10 +357,11 @@ def get_connect_and_main_keyboard(
|
||||
)
|
||||
)
|
||||
|
||||
back_callback = "main_action:back_to_main_keep" if preserve_message else "main_action:back_to_main"
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main",
|
||||
callback_data=back_callback,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
for service_key in (
|
||||
"panel_service",
|
||||
"cryptopay_service",
|
||||
"freekassa_service",
|
||||
"tribute_service",
|
||||
"panel_webhook_service",
|
||||
"yookassa_service",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
Message,
|
||||
Update,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import user_dal
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard
|
||||
|
||||
|
||||
class ChannelSubscriptionMiddleware(BaseMiddleware):
|
||||
"""
|
||||
Blocks access to handlers for users who have not yet passed the required channel subscription check.
|
||||
The /start command is allowed through so that the handler can re-run the verification.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings, i18n_instance: JsonI18n):
|
||||
super().__init__()
|
||||
self.settings = settings
|
||||
self.i18n_main_instance = i18n_instance
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
required_channel_id = self.settings.REQUIRED_CHANNEL_ID
|
||||
if not required_channel_id:
|
||||
return await handler(event, data)
|
||||
|
||||
event_user = data.get("event_from_user")
|
||||
if not event_user or event_user.id in self.settings.ADMIN_IDS:
|
||||
return await handler(event, data)
|
||||
|
||||
callback_query = event.callback_query
|
||||
if (
|
||||
callback_query
|
||||
and callback_query.data
|
||||
and callback_query.data == "channel_subscription:verify"
|
||||
):
|
||||
return await handler(event, data)
|
||||
|
||||
# Allow /start to reach the handler so the check can be re-run.
|
||||
message_object: Optional[Message] = event.message
|
||||
if (
|
||||
message_object
|
||||
and message_object.text
|
||||
and message_object.text.startswith("/start")
|
||||
):
|
||||
return await handler(event, data)
|
||||
|
||||
session: AsyncSession = data["session"]
|
||||
try:
|
||||
db_user = await user_dal.get_user_by_id(session, event_user.id)
|
||||
except Exception as db_error:
|
||||
logging.error(
|
||||
"ChannelSubscriptionMiddleware: failed to fetch user %s: %s",
|
||||
event_user.id,
|
||||
db_error,
|
||||
exc_info=True,
|
||||
)
|
||||
return await handler(event, data)
|
||||
|
||||
if not db_user:
|
||||
return await handler(event, data)
|
||||
|
||||
if (
|
||||
db_user.channel_subscription_verified
|
||||
and db_user.channel_subscription_verified_for == required_channel_id
|
||||
):
|
||||
return await handler(event, data)
|
||||
|
||||
i18n_payload: Dict[str, Any] = data.get("i18n_data", {})
|
||||
current_lang: str = i18n_payload.get(
|
||||
"current_language", self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
i18n_instance: Optional[JsonI18n] = i18n_payload.get(
|
||||
"i18n_instance", self.i18n_main_instance
|
||||
)
|
||||
|
||||
def translate(key: str) -> str:
|
||||
if i18n_instance:
|
||||
return i18n_instance.gettext(current_lang, key)
|
||||
return key
|
||||
|
||||
keyboard = (
|
||||
get_channel_subscription_keyboard(
|
||||
current_lang, i18n_instance, self.settings.REQUIRED_CHANNEL_LINK
|
||||
)
|
||||
if i18n_instance
|
||||
else None
|
||||
)
|
||||
prompt_text = translate("channel_subscription_required")
|
||||
|
||||
if event.callback_query:
|
||||
await self._handle_callback(event.callback_query, prompt_text, keyboard, data)
|
||||
return
|
||||
|
||||
if message_object:
|
||||
await message_object.answer(prompt_text, reply_markup=keyboard)
|
||||
else:
|
||||
bot_instance = data["bot"]
|
||||
await bot_instance.send_message(
|
||||
chat_id=event_user.id,
|
||||
text=prompt_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
return
|
||||
|
||||
async def _handle_callback(
|
||||
self,
|
||||
callback: CallbackQuery,
|
||||
prompt_text: str,
|
||||
keyboard,
|
||||
data: Dict[str, Any],
|
||||
) -> None:
|
||||
try:
|
||||
await callback.answer(prompt_text, show_alert=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if callback.message:
|
||||
try:
|
||||
await callback.message.answer(prompt_text, reply_markup=keyboard)
|
||||
except Exception as send_error:
|
||||
logging.error(
|
||||
"ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s",
|
||||
callback.message.chat.id,
|
||||
send_error,
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
bot_instance = data["bot"]
|
||||
await bot_instance.send_message(
|
||||
chat_id=callback.from_user.id,
|
||||
text=prompt_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
@@ -6,6 +6,7 @@ from aiogram.types import Update, User as TgUser
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.dal import user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class ProfileSyncMiddleware(BaseMiddleware):
|
||||
@@ -24,12 +25,16 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
db_user = await user_dal.get_user_by_id(session, tg_user.id)
|
||||
if db_user:
|
||||
update_payload: Dict[str, Any] = {}
|
||||
if db_user.username != tg_user.username:
|
||||
update_payload["username"] = tg_user.username
|
||||
if db_user.first_name != tg_user.first_name:
|
||||
update_payload["first_name"] = tg_user.first_name
|
||||
if db_user.last_name != tg_user.last_name:
|
||||
update_payload["last_name"] = tg_user.last_name
|
||||
sanitized_username = sanitize_username(tg_user.username)
|
||||
sanitized_first_name = sanitize_display_name(tg_user.first_name)
|
||||
sanitized_last_name = sanitize_display_name(tg_user.last_name)
|
||||
|
||||
if db_user.username != sanitized_username:
|
||||
update_payload["username"] = sanitized_username
|
||||
if db_user.first_name != sanitized_first_name:
|
||||
update_payload["first_name"] = sanitized_first_name
|
||||
if db_user.last_name != sanitized_last_name:
|
||||
update_payload["last_name"] = sanitized_last_name
|
||||
|
||||
if update_payload:
|
||||
await user_dal.update_user(session, tg_user.id, update_payload)
|
||||
@@ -42,10 +47,10 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
panel_service = data.get("panel_service")
|
||||
if panel_service and db_user.panel_user_uuid:
|
||||
description_text = "\n".join([
|
||||
tg_user.username or "",
|
||||
tg_user.first_name or "",
|
||||
tg_user.last_name or "",
|
||||
])
|
||||
username_for_display(tg_user.username, with_at=False) if sanitized_username is not None else "",
|
||||
sanitized_first_name or "",
|
||||
sanitized_last_name or "",
|
||||
]).strip()
|
||||
await panel_service.update_user_details_on_panel(
|
||||
db_user.panel_user_uuid,
|
||||
{"description": description_text},
|
||||
@@ -62,4 +67,3 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from bot.services.referral_service import ReferralService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class CryptoPayService:
|
||||
@@ -189,10 +190,12 @@ class CryptoPayService:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
text = _("payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
||||
@@ -206,7 +209,9 @@ class CryptoPayService:
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link)
|
||||
|
||||
markup = get_connect_and_main_keyboard(lang, i18n, settings, config_link)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang, i18n, settings, config_link, preserve_message=True
|
||||
)
|
||||
try:
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
from aiohttp import ClientSession, ClientTimeout, web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.services.notification_service import NotificationService
|
||||
from db.dal import payment_dal, user_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class FreeKassaService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService,
|
||||
):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
|
||||
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
|
||||
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
|
||||
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
|
||||
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
|
||||
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
|
||||
|
||||
self.api_base_url: str = "https://api.fk.life/v1"
|
||||
self._timeout = ClientTimeout(total=15)
|
||||
self._session: Optional[ClientSession] = None
|
||||
self._nonce_lock = asyncio.Lock()
|
||||
self._last_nonce = int(time.time() * 1000)
|
||||
|
||||
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
|
||||
if not self.configured:
|
||||
logging.warning("FreeKassaService initialized but not fully configured. Payments disabled.")
|
||||
if settings.FREEKASSA_ENABLED and not self.server_ip:
|
||||
logging.warning("FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider.")
|
||||
|
||||
@staticmethod
|
||||
def _format_amount(amount: float) -> str:
|
||||
"""Format amount for payloads and signature with two decimal places."""
|
||||
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
return f"{quantized:.2f}"
|
||||
|
||||
async def create_order(
|
||||
self,
|
||||
*,
|
||||
payment_db_id: int,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
currency: Optional[str],
|
||||
email: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
payment_method_id: Optional[int] = None,
|
||||
extra_params: Optional[Dict[str, Any]] = None,
|
||||
) -> Tuple[bool, Dict[str, Any]]:
|
||||
if not self.configured:
|
||||
logging.error("FreeKassaService is not configured. Cannot create order.")
|
||||
return False, {"message": "service_not_configured"}
|
||||
|
||||
ip_address = ip_address or self.server_ip
|
||||
if not ip_address:
|
||||
logging.error("FreeKassaService: payment IP is required but not configured.")
|
||||
return False, {"message": "missing_ip"}
|
||||
|
||||
email = email or f"{user_id}@telegram.org"
|
||||
amount_str = self._format_amount(amount)
|
||||
currency_code = (currency or self.default_currency or "RUB").upper()
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"shopId": int(self.shop_id),
|
||||
"nonce": await self._generate_nonce(),
|
||||
"paymentId": str(payment_db_id),
|
||||
"i": int(payment_method_id),
|
||||
"amount": amount_str,
|
||||
"currency": currency_code,
|
||||
"email": email,
|
||||
"ip": ip_address,
|
||||
"us_user_id": str(user_id),
|
||||
"us_months": str(months),
|
||||
"us_payment_db_id": str(payment_db_id),
|
||||
}
|
||||
|
||||
if extra_params:
|
||||
for key, value in extra_params.items():
|
||||
if value is None:
|
||||
continue
|
||||
payload[key] = value
|
||||
|
||||
payload["signature"] = self._sign_payload(payload)
|
||||
|
||||
session = await self._get_session()
|
||||
url = f"{self.api_base_url}/orders/create"
|
||||
try:
|
||||
async with session.post(url, json=payload) as response:
|
||||
response_text = await response.text()
|
||||
try:
|
||||
response_data = json.loads(response_text) if response_text else {}
|
||||
except json.JSONDecodeError:
|
||||
logging.error("FreeKassa create_order: failed to decode JSON: %s", response_text)
|
||||
return False, {"status": response.status, "message": "invalid_json", "raw": response_text}
|
||||
|
||||
if response.status != 200 or response_data.get("type") != "success":
|
||||
logging.error(
|
||||
"FreeKassa create_order: API returned error (status=%s, body=%s)",
|
||||
response.status,
|
||||
response_data,
|
||||
)
|
||||
return False, {"status": response.status, "message": response_data}
|
||||
|
||||
return True, response_data
|
||||
except Exception as exc:
|
||||
logging.error("FreeKassa create_order: request failed: %s", exc, exc_info=True)
|
||||
return False, {"message": str(exc)}
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(timeout=self._timeout)
|
||||
return self._session
|
||||
|
||||
async def _generate_nonce(self) -> int:
|
||||
async with self._nonce_lock:
|
||||
candidate = int(time.time() * 1000)
|
||||
if candidate <= self._last_nonce:
|
||||
candidate = self._last_nonce + 1
|
||||
self._last_nonce = candidate
|
||||
return candidate
|
||||
|
||||
def _sign_payload(self, payload: Dict[str, Any]) -> str:
|
||||
if not self.api_key:
|
||||
raise RuntimeError("FreeKassa API key is not configured.")
|
||||
items = [
|
||||
(key, value)
|
||||
for key, value in payload.items()
|
||||
if key != "signature" and value is not None
|
||||
]
|
||||
items.sort(key=lambda pair: pair[0])
|
||||
message = "|".join(str(value) for _, value in items)
|
||||
return hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
|
||||
def _validate_signature(
|
||||
self,
|
||||
merchant_order_id: str,
|
||||
amount: str,
|
||||
provided_signature: str,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
if not provided_signature:
|
||||
return False
|
||||
|
||||
if self.shop_id and self.second_secret:
|
||||
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
||||
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
|
||||
if expected_signature.lower() == provided_signature.lower():
|
||||
return True
|
||||
|
||||
if self.api_key and payload:
|
||||
items = [
|
||||
(key, value)
|
||||
for key, value in payload.items()
|
||||
if key not in {"signature", "SIGN"} and value is not None
|
||||
]
|
||||
items.sort(key=lambda pair: pair[0])
|
||||
message = "|".join(str(value) for _, value in items)
|
||||
alt_signature = hmac.new(self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
if alt_signature.lower() == provided_signature.lower():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured:
|
||||
return web.Response(status=503, text="freekassa_disabled")
|
||||
|
||||
try:
|
||||
data = await request.post()
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa webhook: failed to read POST data: {e}")
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
payload_dict: Dict[str, Any]
|
||||
if data:
|
||||
payload_dict = {str(k): v for k, v in data.items()}
|
||||
else:
|
||||
try:
|
||||
json_payload = await request.json()
|
||||
payload_dict = {str(k): v for k, v in json_payload.items()} if isinstance(json_payload, dict) else {}
|
||||
data = json_payload
|
||||
except Exception:
|
||||
payload_dict = {}
|
||||
data = {}
|
||||
|
||||
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
if isinstance(data, dict):
|
||||
return data.get(key) or data.get(key.lower()) or default
|
||||
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
|
||||
|
||||
merchant_id = _get("MERCHANT_ID")
|
||||
if merchant_id != self.shop_id:
|
||||
logging.error(f"FreeKassa webhook: merchant mismatch (got {merchant_id})")
|
||||
return web.Response(status=403, text="merchant_mismatch")
|
||||
|
||||
signature = _get("SIGN") or _get("signature")
|
||||
if not signature:
|
||||
logging.error("FreeKassa webhook: missing signature")
|
||||
return web.Response(status=400, text="missing_signature")
|
||||
|
||||
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
|
||||
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
|
||||
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
|
||||
|
||||
if not order_id_str or not amount_str:
|
||||
logging.error("FreeKassa webhook: missing order_id or amount")
|
||||
return web.Response(status=400, text="missing_data")
|
||||
|
||||
if not self._validate_signature(order_id_str, amount_str, signature, payload_dict):
|
||||
logging.error("FreeKassa webhook: invalid signature")
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
payment_db_id = int(order_id_str)
|
||||
except (TypeError, ValueError):
|
||||
logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'")
|
||||
return web.Response(status=400, text="invalid_order_id")
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||
if not payment:
|
||||
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
|
||||
return web.Response(status=404, text="payment_not_found")
|
||||
|
||||
if payment.status == "succeeded":
|
||||
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
|
||||
return web.Response(text="YES")
|
||||
|
||||
# Optional amount verification
|
||||
try:
|
||||
amount_decimal = Decimal(amount_str)
|
||||
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount:
|
||||
logging.warning(
|
||||
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
|
||||
f"(expected {expected_amount}, got {amount_decimal})"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
|
||||
|
||||
activation = None
|
||||
referral_bonus = None
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session=session,
|
||||
payment_db_id=payment.payment_id,
|
||||
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
|
||||
new_status="succeeded",
|
||||
)
|
||||
|
||||
months = payment.subscription_duration_months or 1
|
||||
|
||||
activation = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
payment.user_id,
|
||||
months,
|
||||
float(payment.amount),
|
||||
payment.payment_id,
|
||||
provider="freekassa",
|
||||
)
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session,
|
||||
payment.user_id,
|
||||
months,
|
||||
current_payment_db_id=payment.payment_id,
|
||||
skip_if_active_before_payment=False,
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logging.error(f"FreeKassa webhook: failed to process payment {payment_db_id}: {e}", exc_info=True)
|
||||
return web.Response(status=500, text="processing_error")
|
||||
|
||||
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||
|
||||
config_link = None
|
||||
final_end = None
|
||||
months = payment.subscription_duration_months or 1
|
||||
if activation:
|
||||
config_link = activation.get("subscription_url")
|
||||
final_end = activation.get("end_date")
|
||||
|
||||
applied_days = 0
|
||||
if referral_bonus and referral_bonus.get("referee_new_end_date"):
|
||||
final_end = referral_bonus["referee_new_end_date"]
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
|
||||
|
||||
if not final_end and activation and activation.get("end_date"):
|
||||
final_end = activation["end_date"]
|
||||
|
||||
if not config_link:
|
||||
config_link = _("config_link_not_available")
|
||||
if final_end:
|
||||
end_date_str = final_end.strftime("%Y-%m-%d")
|
||||
else:
|
||||
end_date_str = _("config_link_not_available")
|
||||
|
||||
if applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
text = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation["end_date"].strftime("%Y-%m-%d") if activation and activation.get("end_date") else end_date_str,
|
||||
bonus_days=applied_days,
|
||||
final_end_date=end_date_str,
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
text = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=end_date_str,
|
||||
config_link=config_link,
|
||||
)
|
||||
if provider_payment_id:
|
||||
order_info_text = _(
|
||||
"free_kassa_order_full",
|
||||
order_id=provider_payment_id,
|
||||
date=datetime.now().strftime("%Y-%m-%d"),
|
||||
)
|
||||
text = f"{order_info_text}\n{text}"
|
||||
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang,
|
||||
self.i18n,
|
||||
self.settings,
|
||||
config_link,
|
||||
preserve_message=True,
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
payment.user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa notification: failed to send message to user {payment.user_id}: {e}")
|
||||
|
||||
try:
|
||||
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
||||
await notification_service.notify_payment_received(
|
||||
user_id=payment.user_id,
|
||||
amount=float(payment.amount),
|
||||
currency=self.default_currency,
|
||||
months=months,
|
||||
payment_provider="freekassa",
|
||||
username=db_user.username if db_user else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"FreeKassa notification: failed to notify admins: {e}")
|
||||
|
||||
return web.Response(text="YES")
|
||||
|
||||
|
||||
async def freekassa_webhook_route(request: web.Request) -> web.Response:
|
||||
service: FreeKassaService = request.app["freekassa_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -10,6 +10,10 @@ from config.settings import Settings
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.utils.message_queue import get_queue_manager
|
||||
from bot.utils.text_sanitizer import (
|
||||
display_name_or_fallback,
|
||||
username_for_display,
|
||||
)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
@@ -19,6 +23,17 @@ class NotificationService:
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
|
||||
@staticmethod
|
||||
def _format_user_display(
|
||||
user_id: int,
|
||||
username: Optional[str] = None,
|
||||
first_name: Optional[str] = None,
|
||||
) -> str:
|
||||
base_display = display_name_or_fallback(first_name, f"ID {user_id}")
|
||||
if username:
|
||||
base_display = f"{base_display} ({username_for_display(username)})"
|
||||
return base_display
|
||||
|
||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
||||
"""Send message to configured log channel/group using message queue"""
|
||||
@@ -101,9 +116,11 @@ class NotificationService:
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = first_name or f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
referral_text = ""
|
||||
if referred_by_id:
|
||||
@@ -134,12 +151,14 @@ class NotificationService:
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
provider_emoji = {
|
||||
"yookassa": "💳",
|
||||
"freekassa": "💳",
|
||||
"cryptopay": "₿",
|
||||
"stars": "⭐",
|
||||
"tribute": "💎"
|
||||
@@ -174,9 +193,10 @@ class NotificationService:
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_promo_activation",
|
||||
@@ -203,9 +223,10 @@ class NotificationService:
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_trial_activation",
|
||||
@@ -268,9 +289,11 @@ class NotificationService:
|
||||
_ = lambda k, **kw: self.i18n.gettext(
|
||||
admin_lang, k, **kw) if self.i18n else k
|
||||
|
||||
user_display = first_name or f"ID {user_id}"
|
||||
if username:
|
||||
user_display += f" (@{username})"
|
||||
user_display = self._format_user_display(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
first_name=first_name,
|
||||
)
|
||||
|
||||
message = _(
|
||||
"log_suspicious_promo",
|
||||
@@ -295,4 +318,4 @@ class NotificationService:
|
||||
if to_admins:
|
||||
await self._send_to_admins(message)
|
||||
|
||||
# Removed legacy helper functions that duplicated NotificationService API
|
||||
# Removed legacy helper functions that duplicated NotificationService API
|
||||
|
||||
@@ -21,11 +21,11 @@ class PanelApiService:
|
||||
self.api_key = settings.PANEL_API_KEY
|
||||
self._session: Optional[aiohttp.ClientSession] = None
|
||||
self.default_client_ip = "127.0.0.1"
|
||||
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Context manager entry"""
|
||||
return self
|
||||
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit - automatically close session"""
|
||||
await self.close_session()
|
||||
@@ -337,6 +337,7 @@ class PanelApiService:
|
||||
default_expire_days: int = 1,
|
||||
default_traffic_limit_bytes: int = 0,
|
||||
default_traffic_limit_strategy: str = "NO_RESET",
|
||||
hwid_device_limit: Optional[int] = None,
|
||||
specific_squad_uuids: Optional[List[str]] = None,
|
||||
description: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
@@ -368,6 +369,18 @@ class PanelApiService:
|
||||
"trafficLimitStrategy": default_traffic_limit_strategy.upper(),
|
||||
"trafficLimitBytes": default_traffic_limit_bytes,
|
||||
}
|
||||
hwid_limit_value = hwid_device_limit
|
||||
if hwid_limit_value is None:
|
||||
hwid_limit_value = self.settings.USER_HWID_DEVICE_LIMIT
|
||||
if hwid_limit_value is not None:
|
||||
try:
|
||||
hwid_limit_int = int(hwid_limit_value)
|
||||
if hwid_limit_int >= 0:
|
||||
payload["hwidDeviceLimit"] = hwid_limit_int
|
||||
except (TypeError, ValueError):
|
||||
logging.warning(
|
||||
f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'."
|
||||
)
|
||||
if specific_squad_uuids:
|
||||
payload["activeInternalSquads"] = specific_squad_uuids
|
||||
if telegram_id is not None: payload["telegramId"] = telegram_id
|
||||
@@ -442,6 +455,37 @@ class PanelApiService:
|
||||
)
|
||||
return False
|
||||
|
||||
async def delete_user_from_panel(self,
|
||||
user_uuid: str,
|
||||
log_response: bool = True) -> bool:
|
||||
"""Delete a user from the panel. Treat not-found as already deleted."""
|
||||
endpoint = f"/users/{user_uuid}"
|
||||
response_data = await self._request(
|
||||
"DELETE", endpoint, log_full_response=log_response
|
||||
)
|
||||
|
||||
if not response_data:
|
||||
logging.error(
|
||||
f"Panel API delete_user_from_panel returned no data for user {user_uuid}."
|
||||
)
|
||||
return False
|
||||
|
||||
if response_data.get("error"):
|
||||
details = response_data.get("details") or {}
|
||||
error_code = details.get("errorCode") or response_data.get("errorCode")
|
||||
if error_code in {"A062", "A040"}:
|
||||
logging.info(
|
||||
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
|
||||
)
|
||||
return True
|
||||
logging.error(
|
||||
f"Failed to delete user {user_uuid} on panel. Response: {response_data}"
|
||||
)
|
||||
return False
|
||||
|
||||
logging.info(f"Panel user {user_uuid} deleted successfully.")
|
||||
return True
|
||||
|
||||
async def get_subscription_link(
|
||||
self,
|
||||
short_uuid_or_sub_uuid: str,
|
||||
@@ -455,6 +499,30 @@ class PanelApiService:
|
||||
return f"{base_sub_url}/{client_type.lower()}"
|
||||
return base_sub_url
|
||||
|
||||
async def get_user_devices(self, user_uuid: str) -> Optional[List[Dict[str, Any]]]:
|
||||
endpoint = f"/hwid/devices/{user_uuid}"
|
||||
response_data = await self._request("GET", endpoint, log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
logging.error(
|
||||
f"Failed to get user devices for user {user_uuid}. Response: {response_data}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def disconnect_device(self, user_uuid: str, hwid: str) -> bool:
|
||||
endpoint = f"/hwid/devices/delete"
|
||||
payload = {
|
||||
"userUuid": user_uuid,
|
||||
"hwid": hwid
|
||||
}
|
||||
response_data = await self._request("POST", endpoint, json=payload, log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return True
|
||||
logging.error(
|
||||
f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}"
|
||||
)
|
||||
return False
|
||||
|
||||
async def update_bot_db_sync_status(self,
|
||||
session: AsyncSession,
|
||||
status: str,
|
||||
@@ -468,25 +536,24 @@ class PanelApiService:
|
||||
async def get_bot_db_last_sync_status(
|
||||
self, session: AsyncSession) -> Optional[PanelSyncStatus]:
|
||||
return await panel_sync_dal.get_panel_sync_status(session)
|
||||
|
||||
|
||||
|
||||
async def get_system_stats(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get system statistics (CPU, memory, users counts)"""
|
||||
response_data = await self._request("GET", "/system/stats", log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
return None
|
||||
|
||||
|
||||
async def get_bandwidth_stats(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get bandwidth statistics"""
|
||||
response_data = await self._request("GET", "/system/stats/bandwidth", log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
return None
|
||||
|
||||
|
||||
async def get_nodes_statistics(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get nodes statistics"""
|
||||
response_data = await self._request("GET", "/system/stats/nodes", log_full_response=False)
|
||||
if response_data and not response_data.get("error") and "response" in response_data:
|
||||
return response_data.get("response")
|
||||
return None
|
||||
return None
|
||||
|
||||
@@ -12,6 +12,7 @@ from .referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
class StarsService:
|
||||
@@ -124,10 +125,12 @@ class StarsService:
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
@@ -145,7 +148,7 @@ class StarsService:
|
||||
config_link=config_link,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, self.settings, config_link
|
||||
current_lang, i18n, self.settings, config_link, preserve_message=True
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
@@ -173,4 +176,3 @@ class StarsService:
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send stars payment notification: {e}")
|
||||
|
||||
|
||||
@@ -355,6 +355,7 @@ class SubscriptionService:
|
||||
"is_active": True,
|
||||
"status_from_panel": "TRIAL",
|
||||
"traffic_limit_bytes": self.settings.trial_traffic_limit_bytes,
|
||||
"auto_renew_enabled": False,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.upsert_subscription(session, trial_sub_data)
|
||||
@@ -567,6 +568,11 @@ class SubscriptionService:
|
||||
bonus_days: int,
|
||||
reason: str = "bonus",
|
||||
) -> Optional[datetime]:
|
||||
reason_lower = (reason or "").lower()
|
||||
apply_main_traffic_limit = any(
|
||||
keyword in reason_lower for keyword in ("admin", "promo code", "referral", "bonus")
|
||||
)
|
||||
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user:
|
||||
logging.warning(
|
||||
@@ -592,10 +598,14 @@ class SubscriptionService:
|
||||
)
|
||||
start_date = datetime.now(timezone.utc)
|
||||
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
||||
|
||||
# For promo code activations, use the configured user traffic limit
|
||||
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
|
||||
|
||||
|
||||
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise
|
||||
traffic_limit = (
|
||||
self.settings.user_traffic_limit_bytes
|
||||
if apply_main_traffic_limit
|
||||
else self.settings.trial_traffic_limit_bytes
|
||||
)
|
||||
|
||||
bonus_sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
@@ -625,16 +635,27 @@ class SubscriptionService:
|
||||
session, active_sub.subscription_id, new_end_date_obj
|
||||
)
|
||||
|
||||
if (
|
||||
apply_main_traffic_limit
|
||||
and updated_sub_model
|
||||
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
|
||||
):
|
||||
updated_sub_model = await subscription_dal.update_subscription(
|
||||
session,
|
||||
updated_sub_model.subscription_id,
|
||||
{"traffic_limit_bytes": self.settings.user_traffic_limit_bytes},
|
||||
)
|
||||
|
||||
if updated_sub_model:
|
||||
# Prepare panel update payload
|
||||
panel_update_payload = self._build_panel_update_payload(
|
||||
expire_at=new_end_date_obj,
|
||||
traffic_limit_bytes=(
|
||||
self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else None
|
||||
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
|
||||
),
|
||||
include_uuid=False,
|
||||
)
|
||||
|
||||
|
||||
panel_update_success = (
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
@@ -738,8 +759,12 @@ class SubscriptionService:
|
||||
if panel_user_data.get("expireAt")
|
||||
else None
|
||||
)
|
||||
hwid_limit = panel_user_data.get("hwidDeviceLimit")
|
||||
if hwid_limit is None:
|
||||
hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT
|
||||
|
||||
return {
|
||||
"user_id": panel_user_data.get("uuid"),
|
||||
"end_date": panel_end_date,
|
||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||
"config_link": panel_user_data.get("subscriptionUrl"),
|
||||
@@ -747,6 +772,7 @@ class SubscriptionService:
|
||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
"max_devices": hwid_limit,
|
||||
}
|
||||
|
||||
async def get_subscriptions_ending_soon(
|
||||
|
||||
@@ -16,6 +16,7 @@ from bot.services.referral_service import ReferralService
|
||||
from .notification_service import NotificationService
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal, subscription_dal
|
||||
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
||||
|
||||
|
||||
def convert_period_to_months(period: Optional[str]) -> int:
|
||||
@@ -184,10 +185,12 @@ class TributeService:
|
||||
inviter_name_display = _('friend_placeholder')
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
if inviter:
|
||||
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
||||
if safe_name:
|
||||
inviter_name_display = safe_name
|
||||
elif inviter.username:
|
||||
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
@@ -205,7 +208,11 @@ class TributeService:
|
||||
config_link=config_link,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang, i18n, settings, config_link
|
||||
lang,
|
||||
i18n,
|
||||
settings,
|
||||
config_link,
|
||||
preserve_message=True,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -250,8 +257,39 @@ class TributeService:
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
|
||||
try:
|
||||
# Set all user's subscriptions to expire in 1 day (grace period)
|
||||
await subscription_dal.set_user_subscriptions_cancelled_with_grace(session, user_id, grace_days=1)
|
||||
grace_days = 1
|
||||
grace_end = datetime.now(timezone.utc) + timedelta(days=grace_days)
|
||||
|
||||
active_subscriptions = await subscription_dal.get_active_subscriptions_for_user(session, user_id)
|
||||
|
||||
panel_users_updated: set[str] = set()
|
||||
for sub in active_subscriptions:
|
||||
updated_sub = await subscription_dal.update_subscription(
|
||||
session,
|
||||
sub.subscription_id,
|
||||
{
|
||||
"end_date": grace_end,
|
||||
"status_from_panel": "CANCELLED",
|
||||
"skip_notifications": True,
|
||||
},
|
||||
)
|
||||
|
||||
panel_uuid = updated_sub.panel_user_uuid if updated_sub else None
|
||||
if panel_uuid and panel_uuid not in panel_users_updated:
|
||||
panel_users_updated.add(panel_uuid)
|
||||
panel_payload = {
|
||||
"expireAt": grace_end.isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
}
|
||||
try:
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
panel_payload,
|
||||
log_response=False,
|
||||
)
|
||||
except Exception as panel_err:
|
||||
logging.error(
|
||||
f"Failed to update panel expiry for user {user_id} (panel_uuid {panel_uuid}) during Tribute cancellation: {panel_err}")
|
||||
|
||||
await session.commit()
|
||||
|
||||
# Send notification about cancellation if enabled
|
||||
|
||||
@@ -28,6 +28,7 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_user_search = State()
|
||||
waiting_for_subscription_days_to_add = State()
|
||||
waiting_for_direct_message_to_user = State()
|
||||
waiting_for_user_delete_confirmation = State()
|
||||
|
||||
# Ads campaigns
|
||||
waiting_for_ad_source = State()
|
||||
|
||||
@@ -26,6 +26,8 @@ class MessageQueue:
|
||||
self.last_send_times: deque[datetime] = deque()
|
||||
self.is_processing = False
|
||||
self.delay_between_messages = 1.0 / messages_per_second
|
||||
self.total_sent = 0
|
||||
self.total_failed = 0
|
||||
|
||||
async def add_message(self, message: QueuedMessage) -> None:
|
||||
"""Add message to queue"""
|
||||
@@ -50,6 +52,7 @@ class MessageQueue:
|
||||
try:
|
||||
await self._send_message(message)
|
||||
self.last_send_times.append(datetime.now())
|
||||
self.total_sent += 1
|
||||
|
||||
# Keep only recent send times (last minute)
|
||||
cutoff_time = datetime.now() - timedelta(seconds=60)
|
||||
@@ -57,6 +60,7 @@ class MessageQueue:
|
||||
self.last_send_times.popleft()
|
||||
|
||||
except Exception as e:
|
||||
self.total_failed += 1
|
||||
logging.error(f"Failed to send queued message to {message.chat_id}: {e}")
|
||||
|
||||
finally:
|
||||
@@ -233,7 +237,11 @@ class MessageQueueManager:
|
||||
"group_queue_processing": self.group_queue.is_processing,
|
||||
"user_queue_processing": self.user_queue.is_processing,
|
||||
"group_recent_sends": len(self.group_queue.last_send_times),
|
||||
"user_recent_sends": len(self.user_queue.last_send_times)
|
||||
"user_recent_sends": len(self.user_queue.last_send_times),
|
||||
"group_failed_messages": self.group_queue.total_failed,
|
||||
"user_failed_messages": self.user_queue.total_failed,
|
||||
"group_sent_messages": self.group_queue.total_sent,
|
||||
"user_sent_messages": self.user_queue.total_sent,
|
||||
}
|
||||
|
||||
|
||||
@@ -250,4 +258,4 @@ def init_queue_manager(bot: Bot) -> MessageQueueManager:
|
||||
|
||||
def get_queue_manager() -> Optional[MessageQueueManager]:
|
||||
"""Get global queue manager instance"""
|
||||
return _queue_manager
|
||||
return _queue_manager
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Optional
|
||||
|
||||
_OBFUSCATION_CHARS = " .\\-/\\\\•﹒٫_․·∙‧ꞏ‒–—﹘﹣⁻−"
|
||||
|
||||
_URL_PATTERNS = [
|
||||
re.compile(r"(?i)https?://\S+"),
|
||||
re.compile(r"(?i)www\.\S+"),
|
||||
re.compile(r"(?i)tg://\S+"),
|
||||
re.compile(r"(?i)telegram\.me\S*"),
|
||||
re.compile(r"(?i)t\.me/\+\S*"),
|
||||
re.compile(r"(?i)joinchat\S*"),
|
||||
]
|
||||
|
||||
_OBFUSCATED_DOMAIN_PATTERNS = [
|
||||
re.compile(
|
||||
r"(?i)[tт][\s{}\u2022]*[\.{}\u2022]*[\s{}\u2022]*[mм][eе]".format(
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
)
|
||||
),
|
||||
re.compile(
|
||||
r"(?i)[tт][{}\s]*[eе][{}\s]*[lłl1i|][{}\s]*[eе]"
|
||||
r"[{}\s]*[gɢgqг][{}\s]*[rр][{}\s]*[aа]"
|
||||
r"[{}\s]*(?:[mм]|rn)".format(
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
re.escape(_OBFUSCATION_CHARS),
|
||||
)
|
||||
),
|
||||
re.compile(r"(?i)t\.me\S*"),
|
||||
]
|
||||
|
||||
_ENGLISH_SERVICE_PATTERNS = [
|
||||
re.compile(r"(?i)telegram"),
|
||||
re.compile(r"(?i)teleqram"),
|
||||
re.compile(r"(?i)teiegram"),
|
||||
re.compile(r"(?i)teieqram"),
|
||||
re.compile(r"(?i)telegrarn"),
|
||||
re.compile(r"(?i)service"),
|
||||
re.compile(r"(?i)notif(?:ication)?"),
|
||||
re.compile(r"(?i)system"),
|
||||
re.compile(r"(?i)security"),
|
||||
re.compile(r"(?i)safety"),
|
||||
re.compile(r"(?i)support"),
|
||||
re.compile(r"(?i)moderation"),
|
||||
re.compile(r"(?i)review"),
|
||||
re.compile(r"(?i)compliance"),
|
||||
re.compile(r"(?i)abuse"),
|
||||
re.compile(r"(?i)spam"),
|
||||
re.compile(r"(?i)report"),
|
||||
]
|
||||
|
||||
_RUSSIAN_SERVICE_PATTERNS = [
|
||||
re.compile(r"(?i)телеграм\w*"),
|
||||
re.compile(r"(?i)служебн\w*"),
|
||||
re.compile(r"(?i)уведомлен\w*"),
|
||||
re.compile(r"(?i)поддержк\w*"),
|
||||
re.compile(r"(?i)безопасн\w*"),
|
||||
re.compile(r"(?i)модерац\w*"),
|
||||
re.compile(r"(?i)жалоб\w*"),
|
||||
re.compile(r"(?i)абуз\w*"),
|
||||
]
|
||||
|
||||
_PRE_LOWER_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"I": "l",
|
||||
"İ": "l",
|
||||
"Q": "g",
|
||||
"@": " ",
|
||||
}
|
||||
)
|
||||
|
||||
_POST_LOWER_TRANSLATION = str.maketrans(
|
||||
{
|
||||
"а": "a",
|
||||
"б": "b",
|
||||
"в": "v",
|
||||
"г": "g",
|
||||
"д": "d",
|
||||
"е": "e",
|
||||
"ё": "e",
|
||||
"ж": "zh",
|
||||
"з": "z",
|
||||
"и": "i",
|
||||
"і": "i",
|
||||
"й": "i",
|
||||
"к": "k",
|
||||
"л": "l",
|
||||
"м": "m",
|
||||
"н": "n",
|
||||
"о": "o",
|
||||
"п": "p",
|
||||
"р": "r",
|
||||
"с": "s",
|
||||
"т": "t",
|
||||
"у": "u",
|
||||
"ф": "f",
|
||||
"х": "h",
|
||||
"ц": "c",
|
||||
"ч": "ch",
|
||||
"ш": "sh",
|
||||
"щ": "sh",
|
||||
"ъ": "",
|
||||
"ы": "y",
|
||||
"ь": "",
|
||||
"э": "e",
|
||||
"ю": "yu",
|
||||
"я": "ya",
|
||||
"_": "_",
|
||||
}
|
||||
)
|
||||
|
||||
_NORMALIZED_BANNED_TOKENS = {
|
||||
"tme",
|
||||
"telegram",
|
||||
"teleqram",
|
||||
"teiegram",
|
||||
"teieqram",
|
||||
"telegrarn",
|
||||
"joinchat",
|
||||
"http",
|
||||
"https",
|
||||
"www",
|
||||
"tg",
|
||||
"service",
|
||||
"notification",
|
||||
"system",
|
||||
"security",
|
||||
"safety",
|
||||
"support",
|
||||
"moderation",
|
||||
"review",
|
||||
"compliance",
|
||||
"abuse",
|
||||
"spam",
|
||||
"report",
|
||||
}
|
||||
|
||||
_USERNAME_PLACEHOLDER = "клиент"
|
||||
|
||||
|
||||
def _normalize_for_detection(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
normalized = unicodedata.normalize("NFKD", value)
|
||||
normalized = normalized.translate(_PRE_LOWER_TRANSLATION)
|
||||
normalized = normalized.lower()
|
||||
normalized = "".join(
|
||||
ch for ch in normalized if unicodedata.category(ch) != "Mn"
|
||||
)
|
||||
normalized = normalized.translate(_POST_LOWER_TRANSLATION)
|
||||
normalized = normalized.replace("rn", "m")
|
||||
|
||||
pattern = rf"[{re.escape(_OBFUSCATION_CHARS)}\s]+"
|
||||
normalized = re.sub(pattern, "", normalized)
|
||||
normalized = re.sub(r"[^a-z0-9]+", "", normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def _remove_patterns(value: str) -> str:
|
||||
updated = value
|
||||
for pattern in (
|
||||
_URL_PATTERNS
|
||||
+ _OBFUSCATED_DOMAIN_PATTERNS
|
||||
+ _ENGLISH_SERVICE_PATTERNS
|
||||
+ _RUSSIAN_SERVICE_PATTERNS
|
||||
):
|
||||
updated = pattern.sub(" ", updated)
|
||||
return updated
|
||||
|
||||
|
||||
def _finalize(value: str) -> Optional[str]:
|
||||
compacted = re.sub(r"\s+", " ", value)
|
||||
compacted = compacted.strip(" \t\r\n-_.,/\\")
|
||||
compacted = compacted.strip()
|
||||
if not compacted:
|
||||
return None
|
||||
|
||||
normalized = _normalize_for_detection(compacted)
|
||||
if any(token in normalized for token in _NORMALIZED_BANNED_TOKENS):
|
||||
return None
|
||||
return compacted
|
||||
|
||||
|
||||
def sanitize_display_name(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.replace("@", " ")
|
||||
clean = _remove_patterns(clean)
|
||||
return _finalize(clean)
|
||||
|
||||
|
||||
def sanitize_username(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
clean = value.strip()
|
||||
clean = clean.lstrip("@")
|
||||
clean = _remove_patterns(clean)
|
||||
return _finalize(clean)
|
||||
|
||||
|
||||
def username_for_display(username: Optional[str], with_at: bool = False) -> str:
|
||||
sanitized = sanitize_username(username)
|
||||
if not sanitized:
|
||||
return _USERNAME_PLACEHOLDER
|
||||
return f"@{sanitized}" if with_at else sanitized
|
||||
|
||||
|
||||
def display_name_or_fallback(
|
||||
first_name: Optional[str],
|
||||
fallback: Optional[str] = None,
|
||||
) -> str:
|
||||
sanitized = sanitize_display_name(first_name)
|
||||
if sanitized:
|
||||
return sanitized
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
return _USERNAME_PLACEHOLDER
|
||||
@@ -23,6 +23,12 @@ class Settings(BaseSettings):
|
||||
SUPPORT_LINK: Optional[str] = Field(default=None)
|
||||
SERVER_STATUS_URL: Optional[str] = Field(default=None)
|
||||
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
|
||||
REQUIRED_CHANNEL_ID: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Telegram channel ID the user must join to access the bot")
|
||||
REQUIRED_CHANNEL_LINK: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Public username or invite link to the required channel for join button")
|
||||
|
||||
YOOKASSA_SHOP_ID: Optional[str] = None
|
||||
YOOKASSA_SECRET_KEY: Optional[str] = None
|
||||
@@ -44,6 +50,15 @@ class Settings(BaseSettings):
|
||||
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
||||
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
||||
|
||||
FREEKASSA_ENABLED: bool = Field(default=False)
|
||||
FREEKASSA_MERCHANT_ID: Optional[str] = None
|
||||
FREEKASSA_FIRST_SECRET: Optional[str] = None
|
||||
FREEKASSA_SECOND_SECRET: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
|
||||
FREEKASSA_API_KEY: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_IP: Optional[str] = None
|
||||
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
|
||||
|
||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||
STARS_ENABLED: bool = Field(default=True)
|
||||
TRIBUTE_ENABLED: bool = Field(default=True)
|
||||
@@ -123,6 +138,15 @@ class Settings(BaseSettings):
|
||||
|
||||
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
||||
DISABLE_WELCOME_MESSAGE: bool = Field(default=False, description="Disable welcome message on /start command")
|
||||
|
||||
MY_DEVICES_SECTION_ENABLED: bool = Field(
|
||||
default=False,
|
||||
description="Enable the My Devices section in the subscription menu"
|
||||
)
|
||||
USER_HWID_DEVICE_LIMIT: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Default hardware device limit for panel users (0 = unlimited)"
|
||||
)
|
||||
|
||||
# Inline mode thumbnail URLs
|
||||
INLINE_REFERRAL_THUMBNAIL_URL: str = Field(default="https://cdn-icons-png.flaticon.com/512/1077/1077114.png")
|
||||
@@ -236,6 +260,19 @@ class Settings(BaseSettings):
|
||||
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def freekassa_webhook_path(self) -> str:
|
||||
return "/webhook/freekassa"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def freekassa_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.freekassa_webhook_path}"
|
||||
return None
|
||||
|
||||
# Computed YooKassa receipt fields based on recurring toggle
|
||||
@computed_field
|
||||
@property
|
||||
@@ -331,6 +368,22 @@ class Settings(BaseSettings):
|
||||
if isinstance(v, str) and v.strip() == '':
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator('REQUIRED_CHANNEL_LINK', mode='before')
|
||||
@classmethod
|
||||
def sanitize_optional_link(cls, v):
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return None
|
||||
return v
|
||||
|
||||
@field_validator('USER_HWID_DEVICE_LIMIT', mode='before')
|
||||
@classmethod
|
||||
def validate_optional_int(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = v.strip()
|
||||
if not v:
|
||||
return None
|
||||
return v
|
||||
|
||||
# Notification types
|
||||
LOG_NEW_USERS: bool = Field(default=True, description="Send notifications for new user registrations")
|
||||
@@ -366,6 +419,22 @@ def get_settings() -> Settings:
|
||||
logging.warning(
|
||||
"CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work."
|
||||
)
|
||||
if _settings_instance.FREEKASSA_ENABLED:
|
||||
if (
|
||||
not _settings_instance.FREEKASSA_MERCHANT_ID
|
||||
or not _settings_instance.FREEKASSA_API_KEY
|
||||
):
|
||||
logging.warning(
|
||||
"CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work."
|
||||
)
|
||||
if not _settings_instance.FREEKASSA_SECOND_SECRET:
|
||||
logging.warning(
|
||||
"WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified."
|
||||
)
|
||||
if not _settings_instance.subscription_options:
|
||||
logging.warning(
|
||||
"CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons."
|
||||
)
|
||||
|
||||
except ValidationError as e:
|
||||
logging.critical(
|
||||
|
||||
+58
-16
@@ -98,23 +98,37 @@ async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[st
|
||||
trials = (await session.execute(trials_stmt)).scalar() or 0
|
||||
|
||||
# Payers (unique users with succeeded payments)
|
||||
payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(
|
||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
||||
),
|
||||
attrib_subq = (
|
||||
select(
|
||||
AdAttribution.user_id.label("user_id"),
|
||||
AdAttribution.first_start_at.label("first_start_at"),
|
||||
)
|
||||
.where(AdAttribution.ad_campaign_id == campaign_id)
|
||||
.subquery()
|
||||
)
|
||||
payers_stmt = (
|
||||
select(func.count(func.distinct(Payment.user_id)))
|
||||
.select_from(Payment)
|
||||
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
|
||||
.where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.created_at >= attrib_subq.c.first_start_at,
|
||||
)
|
||||
)
|
||||
)
|
||||
payers = (await session.execute(payers_stmt)).scalar() or 0
|
||||
|
||||
# Revenue sum
|
||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(
|
||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
||||
),
|
||||
revenue_stmt = (
|
||||
select(func.coalesce(func.sum(Payment.amount), 0.0))
|
||||
.select_from(Payment)
|
||||
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
|
||||
.where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.created_at >= attrib_subq.c.first_start_at,
|
||||
)
|
||||
)
|
||||
)
|
||||
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||
@@ -151,10 +165,22 @@ async def get_totals(session: AsyncSession) -> Dict[str, float]:
|
||||
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
|
||||
|
||||
# Total revenue from all attributed users (unique users counted across all campaigns)
|
||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.user_id.in_(select(AdAttribution.user_id)),
|
||||
attrib_subq = (
|
||||
select(
|
||||
AdAttribution.user_id.label("user_id"),
|
||||
AdAttribution.first_start_at.label("first_start_at"),
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
revenue_stmt = (
|
||||
select(func.coalesce(func.sum(Payment.amount), 0.0))
|
||||
.select_from(Payment)
|
||||
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
|
||||
.where(
|
||||
and_(
|
||||
Payment.status == "succeeded",
|
||||
Payment.created_at >= attrib_subq.c.first_start_at,
|
||||
)
|
||||
)
|
||||
)
|
||||
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||
@@ -162,3 +188,19 @@ async def get_totals(session: AsyncSession) -> Dict[str, float]:
|
||||
return {"cost": total_cost, "revenue": total_revenue}
|
||||
|
||||
|
||||
async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool:
|
||||
"""Delete ad campaign by id along with related attributions.
|
||||
|
||||
Returns True if campaign existed and was deleted, False otherwise.
|
||||
"""
|
||||
try:
|
||||
campaign = await session.get(AdCampaign, campaign_id)
|
||||
if not campaign:
|
||||
return False
|
||||
await session.delete(campaign)
|
||||
await session.flush()
|
||||
logging.info(f"AdCampaign deleted id={campaign_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
@@ -262,3 +262,37 @@ async def get_last_tribute_payment(
|
||||
Payment.created_at.desc()).limit(1))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
|
||||
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
|
||||
stmt = select(func.sum(Payment.amount)).where(
|
||||
and_(
|
||||
Payment.user_id == user_id,
|
||||
Payment.status == 'succeeded'
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
total = result.scalar()
|
||||
return float(total or 0)
|
||||
|
||||
|
||||
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
|
||||
"""Get total revenue generated from referred users' payments.
|
||||
|
||||
This calculates the sum of all succeeded payments made by users
|
||||
where referred_by_id equals the referrer_id.
|
||||
"""
|
||||
from db.models import User
|
||||
|
||||
stmt = select(func.sum(Payment.amount)).join(
|
||||
User, Payment.user_id == User.user_id
|
||||
).where(
|
||||
and_(
|
||||
User.referred_by_id == referrer_id,
|
||||
Payment.status == 'succeeded'
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
total = result.scalar()
|
||||
return float(total or 0)
|
||||
@@ -14,13 +14,15 @@ async def get_active_subscription_by_user_id(
|
||||
user_id: int,
|
||||
panel_user_uuid: Optional[str] = None) -> Optional[Subscription]:
|
||||
stmt = select(Subscription).where(
|
||||
Subscription.user_id == user_id, Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc))
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date > datetime.now(timezone.utc),
|
||||
)
|
||||
if panel_user_uuid:
|
||||
stmt = stmt.where(Subscription.panel_user_uuid == panel_user_uuid)
|
||||
stmt = stmt.order_by(Subscription.end_date.desc())
|
||||
stmt = stmt.order_by(Subscription.end_date.desc()).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def get_subscription_by_panel_subscription_uuid(
|
||||
|
||||
+72
-2
@@ -3,11 +3,20 @@ from typing import Optional, List, Dict, Any, Tuple
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy import update, delete, func, and_
|
||||
from sqlalchemy import update, delete, func, and_, or_
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
from ..models import User, Subscription
|
||||
from ..models import (
|
||||
User,
|
||||
Subscription,
|
||||
Payment,
|
||||
PromoCodeActivation,
|
||||
MessageLog,
|
||||
UserBilling,
|
||||
UserPaymentMethod,
|
||||
AdAttribution,
|
||||
)
|
||||
|
||||
|
||||
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||
@@ -102,6 +111,29 @@ async def get_banned_users(session: AsyncSession) -> List[User]:
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_all_users_paginated(
|
||||
session: AsyncSession, *, page: int = 0, page_size: int = 15
|
||||
) -> List[User]:
|
||||
"""Return a slice of users ordered by newest registration first."""
|
||||
safe_page = max(page, 0)
|
||||
safe_page_size = max(page_size, 1)
|
||||
|
||||
stmt = (
|
||||
select(User)
|
||||
.order_by(User.registration_date.desc())
|
||||
.offset(safe_page * safe_page_size)
|
||||
.limit(safe_page_size)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def count_all_users(session: AsyncSession) -> int:
|
||||
"""Count total number of users."""
|
||||
result = await session.execute(select(func.count(User.user_id)))
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
||||
stmt = select(User.user_id).where(User.is_banned == False)
|
||||
result = await session.execute(stmt)
|
||||
@@ -227,3 +259,41 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||
"""Completely remove a user and all dependent records from the database.
|
||||
|
||||
This helper ensures we do not leave dangling foreign keys or orphaned data.
|
||||
"""
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if not user:
|
||||
return False
|
||||
|
||||
# Ensure referral pointers do not block deletion
|
||||
await session.execute(
|
||||
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
|
||||
)
|
||||
|
||||
# Clean up dependent tables that do not cascade automatically
|
||||
await session.execute(
|
||||
delete(MessageLog).where(
|
||||
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
|
||||
)
|
||||
)
|
||||
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||
await session.execute(
|
||||
delete(Subscription).where(Subscription.user_id == user_id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id)
|
||||
)
|
||||
await session.execute(
|
||||
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||
)
|
||||
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||
|
||||
await session.delete(user)
|
||||
await session.flush()
|
||||
return True
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from .models import Base
|
||||
from .migrator import run_simple_migrations
|
||||
from .migrator import run_database_migrations
|
||||
|
||||
async_engine = None
|
||||
|
||||
@@ -63,8 +63,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker):
|
||||
|
||||
async with async_engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Run lightweight, idempotent migrations to add any missing columns
|
||||
await conn.run_sync(run_simple_migrations)
|
||||
await conn.run_sync(run_database_migrations)
|
||||
logging.info(
|
||||
"PostgreSQL database initialized/checked successfully using SQLAlchemy."
|
||||
)
|
||||
|
||||
+85
-53
@@ -1,66 +1,98 @@
|
||||
import logging
|
||||
from typing import Set
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, List, Set
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from .models import Base
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Migration:
|
||||
id: str
|
||||
description: str
|
||||
upgrade: Callable[[Connection], None]
|
||||
|
||||
|
||||
def _add_missing_columns(connection: Connection) -> None:
|
||||
def _ensure_migrations_table(connection: Connection) -> None:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migration_0001_add_channel_subscription_fields(connection: Connection) -> None:
|
||||
inspector = inspect(connection)
|
||||
metadata = Base.metadata
|
||||
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||
statements: List[str] = []
|
||||
|
||||
existing_tables: Set[str] = set(inspector.get_table_names())
|
||||
if "channel_subscription_verified" not in columns:
|
||||
statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified BOOLEAN"
|
||||
)
|
||||
if "channel_subscription_checked_at" not in columns:
|
||||
statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_checked_at TIMESTAMPTZ"
|
||||
)
|
||||
if "channel_subscription_verified_for" not in columns:
|
||||
statements.append(
|
||||
"ALTER TABLE users ADD COLUMN channel_subscription_verified_for BIGINT"
|
||||
)
|
||||
|
||||
for table in metadata.tables.values():
|
||||
table_name = table.name
|
||||
if table_name not in existing_tables:
|
||||
# Tables are created elsewhere via create_all; skip here.
|
||||
for stmt in statements:
|
||||
connection.execute(text(stmt))
|
||||
|
||||
|
||||
MIGRATIONS: List[Migration] = [
|
||||
Migration(
|
||||
id="0001_add_channel_subscription_fields",
|
||||
description="Add columns to track required channel subscription verification",
|
||||
upgrade=_migration_0001_add_channel_subscription_fields,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def run_database_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Apply pending migrations sequentially. Already applied revisions are skipped.
|
||||
"""
|
||||
_ensure_migrations_table(connection)
|
||||
|
||||
applied_revisions: Set[str] = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
text("SELECT id FROM schema_migrations")
|
||||
)
|
||||
}
|
||||
|
||||
for migration in MIGRATIONS:
|
||||
if migration.id in applied_revisions:
|
||||
continue
|
||||
|
||||
existing_columns = {col_info["name"] for col_info in inspector.get_columns(table_name)}
|
||||
|
||||
for desired_column in table.columns:
|
||||
if desired_column.name in existing_columns:
|
||||
continue
|
||||
|
||||
# Build ADD COLUMN DDL
|
||||
preparer = connection.dialect.identifier_preparer
|
||||
table_quoted = preparer.format_table(table)
|
||||
column_name_quoted = preparer.quote(desired_column.name)
|
||||
column_type_sql = desired_column.type.compile(dialect=connection.dialect)
|
||||
|
||||
default_clause = ""
|
||||
server_default = getattr(desired_column, "server_default", None)
|
||||
if server_default is not None and getattr(server_default, "arg", None) is not None:
|
||||
try:
|
||||
compiled_default = str(
|
||||
server_default.arg.compile(dialect=connection.dialect)
|
||||
)
|
||||
default_clause = f" DEFAULT {compiled_default}"
|
||||
except Exception: # best-effort
|
||||
pass
|
||||
|
||||
# For safety, add new columns as NULLable to avoid failures on existing rows
|
||||
# If strict NOT NULL is needed, it can be enforced manually later.
|
||||
ddl = f"ALTER TABLE {table_quoted} ADD COLUMN {column_name_quoted} {column_type_sql}{default_clause}"
|
||||
|
||||
logging.info(
|
||||
f"Migrator: adding missing column {desired_column.name} to table {table_name}"
|
||||
logging.info(
|
||||
"Migrator: applying %s – %s", migration.id, migration.description
|
||||
)
|
||||
try:
|
||||
with connection.begin_nested():
|
||||
migration.upgrade(connection)
|
||||
connection.execute(
|
||||
text(
|
||||
"INSERT INTO schema_migrations (id) VALUES (:revision)"
|
||||
),
|
||||
{"revision": migration.id},
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.error(
|
||||
"Migrator: failed to apply %s (%s)",
|
||||
migration.id,
|
||||
migration.description,
|
||||
exc_info=True,
|
||||
)
|
||||
connection.execute(text(ddl))
|
||||
|
||||
|
||||
def run_simple_migrations(connection: Connection) -> None:
|
||||
"""
|
||||
Run lightweight, idempotent migrations:
|
||||
- Ensure missing columns are added to existing tables to match models in db/models.py
|
||||
Note: Table creation is handled separately via Base.metadata.create_all.
|
||||
"""
|
||||
try:
|
||||
_add_missing_columns(connection)
|
||||
logging.info("Migrator: schema synchronized (columns added as needed).")
|
||||
except Exception as e:
|
||||
logging.error(f"Migrator: failed to run simple migrations: {e}", exc_info=True)
|
||||
raise
|
||||
raise exc
|
||||
else:
|
||||
logging.info("Migrator: migration %s applied successfully", migration.id)
|
||||
|
||||
@@ -24,6 +24,10 @@ class User(Base):
|
||||
referred_by_id = Column(BigInteger,
|
||||
ForeignKey("users.user_id"),
|
||||
nullable=True)
|
||||
channel_subscription_verified = Column(Boolean, nullable=True)
|
||||
channel_subscription_checked_at = Column(DateTime(timezone=True),
|
||||
nullable=True)
|
||||
channel_subscription_verified_for = Column(BigInteger, nullable=True)
|
||||
|
||||
referrer = relationship("User", remote_side=[user_id], backref="referrals")
|
||||
subscriptions = relationship("Subscription",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
services:
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 8080:8080
|
||||
networks:
|
||||
- remnawave-network
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- remnawave-tg-shop-db
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
hostname: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
networks:
|
||||
remnawave-network: null
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
+54
-12
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"welcome": "Welcome, {user_name}!",
|
||||
"channel_subscription_required": "To use the bot, join the channel and tap \"Check subscription\".",
|
||||
"channel_subscription_join_button": "Open channel",
|
||||
"channel_subscription_verify_button": "Check subscription",
|
||||
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
|
||||
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
|
||||
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?",
|
||||
|
||||
"menu_activate_trial_button": "🆓 Free Trial",
|
||||
"menu_subscribe_inline": "🚀 Purchase",
|
||||
"menu_my_subscription_inline": "🔐 My Subscription",
|
||||
"no_subscription options available": "Subscription issuance is not configured by the bot administrator",
|
||||
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
|
||||
"menu_referral_inline": "🎁 Referrals",
|
||||
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
|
||||
"menu_apply_promo_button": "🎟 Promo Code",
|
||||
@@ -13,37 +17,53 @@
|
||||
"menu_server_status_button": "📊 Status",
|
||||
"menu_support_button": "💬 Support",
|
||||
"menu_terms_button": "📄 Terms of Service",
|
||||
|
||||
"back_to_main_menu_button": "⬅️ Back",
|
||||
|
||||
"choose_language": "Choose language:",
|
||||
"language_set_alert": "Language changed!",
|
||||
|
||||
"error_occurred_try_again": "An error occurred, please try again.",
|
||||
"error_try_again": "Please try again.",
|
||||
"error_displaying_menu": "Error displaying menu.",
|
||||
"main_menu_unknown_action": "Unknown action.",
|
||||
|
||||
"select_subscription_period": "Select subscription period:",
|
||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||
|
||||
"choose_payment_method": "Choose payment method:",
|
||||
"pay_button": "💳 Pay",
|
||||
"pay_with_yookassa_button": "💳 YooKassa",
|
||||
"yookassa_autopay_flow_prompt": "Auto-renew is enabled. Choose how you'd like to pay:",
|
||||
"yookassa_autopay_pay_saved_card_button": "💳 Pay with saved card",
|
||||
"yookassa_autopay_pay_new_card_button": "➕ Pay with new card",
|
||||
"yookassa_autopay_choose_saved_card": "Choose a saved card to charge:",
|
||||
"yookassa_autopay_no_saved_cards": "No saved cards found. Pay with a new card or link one in Payment Methods.",
|
||||
"back_to_autopay_method_choice_button": "⬅️ Back",
|
||||
"yookassa_autopay_charge_initiated": "Charge request sent to the selected card. We'll notify you once the payment completes.",
|
||||
"pay_with_sbp_button": "📱 SBP",
|
||||
"back_to_payment_methods_button": "⬅️ Back",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
"connect_button": "🔗 Connect",
|
||||
"cancel_button": "❌ Cancel",
|
||||
|
||||
"devices_button": "📱 My Devices ({current_devices}/{max_devices})",
|
||||
"my_devices_details": "📱 <b>My Devices ({current_devices}/{max_devices})</b>\n\n{devices}\n\nYou can disconnect a device by selecting it from the list below.\n<blockquote><i>Note: If you disconnect a device, it will be automatically connected again when you use it next. Before deleting, make sure you have deleted the subscription from the application.</i></blockquote>",
|
||||
"no_devices_details_found_message": "📱 <b>My Devices</b>\n\nYou don't have any devices yet.\nYou have {max_devices} devices available. You can connect them by clicking the \"🔗 Connect\" button in the subscription menu.",
|
||||
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nDate connected: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
|
||||
"disconnect_device_button": "🚫 Disconnect {hwid} ({index})",
|
||||
"device_disconnected": "Device disconnected successfully.",
|
||||
"no_devices_found": "Error loading device list.",
|
||||
"devices_unlimited_label": "Unlimited",
|
||||
"my_devices_feature_disabled": "The My Devices section is currently unavailable.",
|
||||
|
||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||
"free_kassa_order_info": "Order #{order_id} from {date}",
|
||||
"payment_invoice_sent_message": "Telegram has sent the invoice above. Complete the payment or pick another method below.",
|
||||
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
|
||||
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
|
||||
"config_link_not_available": "not available, contact support",
|
||||
"traffic_unlimited": "Unlimited",
|
||||
|
||||
"promo_code_prompt": "Please enter your promo code:",
|
||||
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
|
||||
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
|
||||
@@ -65,7 +85,6 @@
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
|
||||
"user_is_banned": "🚫 Your account is banned. Please contact support.",
|
||||
|
||||
"admin_panel_title": "Admin Panel",
|
||||
"admin_stats_button": "📊 Statistics",
|
||||
"admin_broadcast_button": "📢 Broadcast",
|
||||
@@ -82,7 +101,10 @@
|
||||
"admin_promo_marketing_section": "🎁 Promos",
|
||||
"admin_system_functions_section": "⚙️ System",
|
||||
"admin_ban_management_section": "🚫 Bans",
|
||||
"admin_users_management_button": "👤 Management",
|
||||
"admin_users_search_button": "🔍 Search",
|
||||
"admin_users_management_button": "👥 User List",
|
||||
"admin_users_list_header": "👥 <b>User List</b>\n\nPage {current}/{total} ({total_users} users)",
|
||||
"admin_user_back_to_list_button": "⬅️ Back to List",
|
||||
"back_to_user_management_button": "⬅️ To Users",
|
||||
"back_to_admin_panel_button": "⬅️ To Admin",
|
||||
"admin_stats_header": "📊 Bot Statistics",
|
||||
@@ -225,9 +247,20 @@
|
||||
"admin_user_send_message_button": "✉️ Send Message",
|
||||
"admin_user_view_logs_button": "📜 User Actions",
|
||||
"admin_user_refresh_button": "🔄 Refresh",
|
||||
"admin_user_delete_button": "❌ Delete User",
|
||||
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
|
||||
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
|
||||
"admin_user_delete_cancelled": "Deletion cancelled.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.",
|
||||
"admin_user_delete_already_removed": "ℹ️ The user is already removed.",
|
||||
"admin_user_delete_panel_error": "❌ Failed to remove the user from the panel. Operation aborted.",
|
||||
"admin_user_delete_success": "✅ User {user_id} was removed from the bot and the panel.",
|
||||
"admin_user_delete_error": "❌ Unable to delete the user. Please try again later.",
|
||||
"admin_user_search_new_button": "🔍 Find Another",
|
||||
"admin_user_view_all_logs_button": "📋 All Actions",
|
||||
"admin_user_back_to_card_button": "🔙 Back to Card",
|
||||
"admin_user_recent_actions_title": "📜 Recent actions of user {user_id}:",
|
||||
"admin_user_not_found": "❌ User not found: {input}",
|
||||
"admin_user_not_found_action": "User not found",
|
||||
"admin_user_card_error": "❌ Error displaying user card",
|
||||
@@ -237,6 +270,7 @@
|
||||
"admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.",
|
||||
"admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}",
|
||||
"admin_user_subscription_added_error": "❌ Error adding subscription days",
|
||||
"admin_panel_user_creation_failed": "❌ Failed to create or link panel user for ID {user_id}. Manual intervention required.",
|
||||
"admin_user_ban_toggle_success": "✅ User {status}",
|
||||
"admin_user_ban_toggle_error": "❌ Error changing ban status",
|
||||
"admin_user_ban_success": "✅ User {input} has been banned",
|
||||
@@ -316,10 +350,13 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "No active subscription",
|
||||
"admin_user_actions_count_label": "📜 <b>Total actions:</b>",
|
||||
"admin_user_total_paid_label": "💰 <b>Total Paid:</b>",
|
||||
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
|
||||
"admin_user_subscription_error": "Loading error",
|
||||
"admin_promo_management_button": "🎟 Promo Management",
|
||||
|
||||
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
|
||||
"admin_promo_management_empty": "📭 No promo codes available",
|
||||
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
|
||||
@@ -428,5 +465,10 @@
|
||||
"admin_ads_back_to_menu_hint": "Done. Back to Ads section:",
|
||||
"admin_ads_overview": "📈 <b>Ads</b>\n💰 Revenue: <b>{revenue} RUB</b>\n💸 Spent: <b>{cost} RUB</b>",
|
||||
"back_to_ads_list_button": "⬅️ Back to list",
|
||||
"admin_ads_card": "📈 <b>Campaign #{id}</b>\nSource: <b>{source}</b>\nstart=<code>{start_param}</code>\nCost: <b>{cost} RUB</b>\nActive: {active}\n\n👥 Starts: <b>{starts}</b>\n🆓 Trials: <b>{trials}</b>\n💳 Payers: <b>{payers}</b>\n💵 Revenue: <b>{revenue} RUB</b>"
|
||||
"admin_ads_card": "📈 <b>Campaign #{id}</b>\nSource: <b>{source}</b>\nstart=<code>{start_param}</code>\nCost: <b>{cost} RUB</b>\nActive: {active}\n\n👥 Starts: <b>{starts}</b>\n🆓 Trials: <b>{trials}</b>\n💳 Payers: <b>{payers}</b>\n💵 Revenue: <b>{revenue} RUB</b>",
|
||||
"admin_ads_delete_button": "🗑 Delete campaign",
|
||||
"admin_ads_delete_confirm": "Are you sure you want to delete campaign #{id}? This action is irreversible.",
|
||||
"admin_ads_deleted_success": "Campaign deleted.",
|
||||
"admin_ads_not_found": "Campaign not found.",
|
||||
"free_kassa_order_full": "Order #{order_id} from {date}\n\n"
|
||||
}
|
||||
|
||||
+54
-11
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"welcome": "Добро пожаловать, {user_name}!",
|
||||
"channel_subscription_required": "Чтобы пользоваться ботом, подпишитесь на канал и нажмите \"Проверить подписку\".",
|
||||
"channel_subscription_join_button": "Перейти в канал",
|
||||
"channel_subscription_verify_button": "Проверить подписку",
|
||||
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
|
||||
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
|
||||
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?",
|
||||
|
||||
"menu_activate_trial_button": "🆓 Пробный период",
|
||||
"menu_subscribe_inline": "🚀 Купить",
|
||||
"menu_my_subscription_inline": "🔐 Моя подписка",
|
||||
@@ -13,37 +17,53 @@
|
||||
"menu_server_status_button": "📊 Статус",
|
||||
"menu_support_button": "💬 Поддержка",
|
||||
"menu_terms_button": "📄 Условия сервиса",
|
||||
|
||||
"back_to_main_menu_button": "⬅️ Назад",
|
||||
|
||||
"choose_language": "Выберите язык / Select language:",
|
||||
"language_set_alert": "Язык изменен!",
|
||||
|
||||
"error_occurred_try_again": "Произошла ошибка, попробуйте снова.",
|
||||
"error_try_again": "Попробуйте еще раз.",
|
||||
"error_displaying_menu": "Ошибка отображения меню.",
|
||||
"main_menu_unknown_action": "Неизвестное действие.",
|
||||
|
||||
"select_subscription_period": "Выберите срок подписки:",
|
||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||
|
||||
"choose_payment_method": "Выберите способ оплаты:",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||
"yookassa_autopay_flow_prompt": "Автопродление включено. Выберите, как оплатить подписку:",
|
||||
"yookassa_autopay_pay_saved_card_button": "💳 Оплата привязанной картой",
|
||||
"yookassa_autopay_pay_new_card_button": "➕ Оплата новой картой",
|
||||
"yookassa_autopay_choose_saved_card": "Выберите привязанную карту для списания:",
|
||||
"yookassa_autopay_no_saved_cards": "Сохранённых карт нет. Оплатите новой картой или привяжите карту в разделе «Способы оплаты».",
|
||||
"back_to_autopay_method_choice_button": "⬅️ Назад",
|
||||
"yookassa_autopay_charge_initiated": "Запрос на списание с выбранной карты отправлен. Сообщим, как только платёж завершится.",
|
||||
"pay_with_sbp_button": "📱 СБП",
|
||||
"back_to_payment_methods_button": "⬅️ Назад",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
"connect_button": "🔗 Подключиться",
|
||||
|
||||
"devices_button": "📱 Мои устройства ({current_devices}/{max_devices})",
|
||||
"my_devices_details": "📱 <b>Список ваших устройств ({current_devices}/{max_devices})</b>\n\n{devices}\n\nВы можете отключить устройство, выбрав его в списке ниже.\n<blockquote><i>Примечание: Если вы отключили устройство, оно будет автоматически подключено заново при следующем использовании. Перед удалением убедитесь, что вы удалили подписку из приложения.</i></blockquote>",
|
||||
"no_devices_details_found_message": "📱 <b>Список ваших устройств</b>\n\nУ вас пока нет устройств.\nВам доступно {max_devices} устройств. Подключить их можно через кнопку \"🔗 Подключиться\" в меню подписки.",
|
||||
"device_details": "{index}) <b>{device_model} ({platform} {os_version})</b>\nДата подключения: <b>{created_at_str}</b>\nUser Agent: <b>{user_agent}</b>\nHWID: <code>{hwid}</code>",
|
||||
"disconnect_device_button": "🚫 Отключить {hwid} ({index})",
|
||||
"device_disconnected": "Устройство успешно отключено.",
|
||||
"no_devices_found": "Ошибка загрузки списка устройств.",
|
||||
"devices_unlimited_label": "Без ограничений",
|
||||
"my_devices_feature_disabled": "Раздел \"Мои устройства\" сейчас недоступен.",
|
||||
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||
"free_kassa_order_info": "Заказ №{order_id} от {date}",
|
||||
"payment_invoice_sent_message": "Счёт Telegram Stars отправлен выше. Нажмите «Оплатить» или вернитесь к выбору способа ниже.",
|
||||
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
||||
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||
"config_link_not_available": "недоступна, обратитесь в поддержку",
|
||||
"traffic_unlimited": "Безлимитный",
|
||||
|
||||
"promo_code_prompt": "Пожалуйста, введите ваш промокод:",
|
||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||
@@ -65,7 +85,6 @@
|
||||
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
|
||||
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
|
||||
"user_is_banned": "🚫 Ваш аккаунт заблокирован. Пожалуйста, свяжитесь со службой поддержки.",
|
||||
|
||||
"admin_panel_title": "Панель администратора",
|
||||
"admin_stats_button": "📊 Статистика",
|
||||
"admin_broadcast_button": "📢 Рассылка",
|
||||
@@ -82,7 +101,10 @@
|
||||
"admin_promo_marketing_section": "🎁 Промокоды",
|
||||
"admin_system_functions_section": "⚙️ Система",
|
||||
"admin_ban_management_section": "🚫 Блокировки",
|
||||
"admin_users_management_button": "👤 Управление",
|
||||
"admin_users_search_button": "🔍 Поиск",
|
||||
"admin_users_management_button": "👥 Список пользователей",
|
||||
"admin_users_list_header": "👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||
"admin_user_back_to_list_button": "⬅️ К списку",
|
||||
"back_to_user_management_button": "⬅️ К пользователям",
|
||||
"back_to_admin_panel_button": "⬅️ В админку",
|
||||
"admin_stats_header": "📊 Статистика Бота",
|
||||
@@ -131,6 +153,7 @@
|
||||
"admin_broadcast_cancelled": "Рассылка отменена.",
|
||||
"admin_broadcast_cancelled_alert": "Рассылка отменена!",
|
||||
"admin_broadcast_cancelled_nav_back": "Рассылка отменена. Вы возвращены в админ-панель.",
|
||||
"broadcast_queue_result": "🚀 Рассылка поставлена в очередь!\n📤 В очередь добавлено: {sent_count}\n❌ Ошибок: {failed_count}\n\n📊 Статус очередей:\n👥 Очередь пользователей: {user_queue_size} сообщений\n📢 Очередь групп: {group_queue_size} сообщений\n\nℹ️ Сообщения будут отправлены автоматически с соблюдением лимитов Telegram.",
|
||||
"admin_promo_invalid_code_format": "Код должен быть от 3 до 30 символов и содержать только буквы и цифры.",
|
||||
"admin_promo_invalid_bonus_days": "Количество бонусных дней должно быть положительным числом.",
|
||||
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
|
||||
@@ -224,9 +247,20 @@
|
||||
"admin_user_send_message_button": "✉️ Сообщение",
|
||||
"admin_user_view_logs_button": "📜 Логи",
|
||||
"admin_user_refresh_button": "🔄 Обновить",
|
||||
"admin_user_delete_button": "❌ Удалить пользователя",
|
||||
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
|
||||
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
|
||||
"admin_user_delete_cancelled": "Удаление отменено.",
|
||||
"admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.",
|
||||
"admin_user_delete_already_removed": "ℹ️ Пользователь уже удалён.",
|
||||
"admin_user_delete_panel_error": "❌ Не удалось удалить пользователя на панели. Операция прервана.",
|
||||
"admin_user_delete_success": "✅ Пользователь {user_id} удалён из бота и панели.",
|
||||
"admin_user_delete_error": "❌ Не удалось удалить пользователя. Попробуйте позже.",
|
||||
"admin_user_search_new_button": "🔍 Новый поиск",
|
||||
"admin_user_view_all_logs_button": "📋 Все логи",
|
||||
"admin_user_back_to_card_button": "🔙 К карточке",
|
||||
"admin_user_recent_actions_title": "📜 Последние действия пользователя {user_id}:",
|
||||
"admin_user_not_found": "❌ Пользователь не найден: {input}",
|
||||
"admin_user_not_found_action": "Пользователь не найден",
|
||||
"admin_user_card_error": "❌ Ошибка отображения карточки пользователя",
|
||||
@@ -236,6 +270,7 @@
|
||||
"admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.",
|
||||
"admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
|
||||
"admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки",
|
||||
"admin_panel_user_creation_failed": "❌ Не удалось создать или привязать пользователя в панели для ID {user_id}. Требуется ручная проверка.",
|
||||
"admin_user_ban_toggle_success": "✅ Пользователь {status}",
|
||||
"admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки",
|
||||
"admin_user_ban_success": "✅ Пользователь {input} заблокирован",
|
||||
@@ -315,10 +350,13 @@
|
||||
"admin_user_na_value": "N/A",
|
||||
"admin_user_subscription_none": "Нет активной подписки",
|
||||
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
|
||||
"admin_user_total_paid_label": "💰 <b>Всего оплачено:</b>",
|
||||
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
|
||||
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
|
||||
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
|
||||
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
|
||||
"admin_user_subscription_error": "Ошибка загрузки",
|
||||
"admin_promo_management_button": "🎟 Управление промокодами",
|
||||
|
||||
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
|
||||
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
|
||||
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
|
||||
@@ -427,5 +465,10 @@
|
||||
"admin_ads_back_to_menu_hint": "Готово. Вернуться к разделу рекламы:",
|
||||
"admin_ads_overview": "📈 <b>Реклама</b>\n💰 Пришло: <b>{revenue} RUB</b>\n💸 Потрачено: <b>{cost} RUB</b>",
|
||||
"back_to_ads_list_button": "⬅️ К списку",
|
||||
"admin_ads_card": "📈 <b>Кампания #{id}</b>\nИсточник: <b>{source}</b>\nstart=<code>{start_param}</code>\nСтоимость: <b>{cost} RUB</b>\nАктивна: {active}\n\n👥 Запустили: <b>{starts}</b>\n🆓 Взяли триал: <b>{trials}</b>\n💳 Оплатили: <b>{payers}</b>\n💵 Доход: <b>{revenue} RUB</b>"
|
||||
"admin_ads_card": "📈 <b>Кампания #{id}</b>\nИсточник: <b>{source}</b>\nstart=<code>{start_param}</code>\nСтоимость: <b>{cost} RUB</b>\nАктивна: {active}\n\n👥 Запустили: <b>{starts}</b>\n🆓 Взяли триал: <b>{trials}</b>\n💳 Оплатили: <b>{payers}</b>\n💵 Доход: <b>{revenue} RUB</b>",
|
||||
"admin_ads_delete_button": "🗑 Удалить кампанию",
|
||||
"admin_ads_delete_confirm": "Вы уверены, что хотите удалить кампанию #{id}? Это действие необратимо.",
|
||||
"admin_ads_deleted_success": "Кампания удалена.",
|
||||
"admin_ads_not_found": "Кампания не найдена.",
|
||||
"free_kassa_order_full": "Заказ №{order_id} от {date}\n\n"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user