Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
956ec5ea71 | ||
|
|
2ef420ff47 | ||
|
|
7de1cdf9d6 | ||
|
|
860c2298bd | ||
|
|
2eff9300d3 | ||
|
|
1bd09be005 | ||
|
|
ce571f6566 | ||
|
|
856129fce2 | ||
|
|
b67f659746 | ||
|
|
a102903814 | ||
|
|
7f6cca2f59 | ||
|
|
3e6ef9de32 | ||
|
|
9cf395c103 | ||
|
|
69429dce81 | ||
|
|
d3bfe774c7 | ||
|
|
e2df8a01c8 | ||
|
|
dbeee31081 | ||
|
|
aa5a68c32c | ||
|
|
a7b4863889 | ||
|
|
16a00efcc9 | ||
|
|
2779e62e43 | ||
|
|
d7fdcc25cd | ||
|
|
24c4bbc27e | ||
|
|
5b08ad5449 | ||
|
|
fa321426b8 | ||
|
|
a4839ffc63 | ||
|
|
0aa38d3806 | ||
|
|
c7b124ad4c | ||
|
|
b5b740611c | ||
|
|
68459a7075 | ||
|
|
0e84a480fc | ||
|
|
1ac368354d | ||
|
|
831e61cd99 | ||
|
|
981d63c53e | ||
|
|
4d8cf45bda | ||
|
|
df987fc52a | ||
|
|
07cb68457f | ||
|
|
1e666f90d3 | ||
|
|
6241f766a2 | ||
|
|
451581f792 | ||
|
|
a015946b20 | ||
|
|
d1d701bff3 | ||
|
|
ea50567a42 | ||
|
|
a8b62ccc03 | ||
|
|
0e03d72dd3 | ||
|
|
aa5eb74d75 | ||
|
|
fd2d0dd570 | ||
|
|
abaaf65da9 | ||
|
|
699964adc0 | ||
|
|
74b5587c77 | ||
|
|
d196801e2e | ||
|
|
c12958e9a2 | ||
|
|
f4bb61dd1b | ||
|
|
24a7e6cc6a | ||
|
|
c6f5e06eba | ||
|
|
ca35567012 | ||
|
|
cdefe27f89 | ||
|
|
7200d6e342 | ||
|
|
fd4b9b828b | ||
|
|
b2a0ea5bd2 | ||
|
|
b97774c5b5 | ||
|
|
d5b4f18dbc | ||
|
|
a94ee8b554 | ||
|
|
7a90e24427 | ||
|
|
891f9354ac | ||
|
|
89709d6626 | ||
|
|
eea6afbcc7 | ||
|
|
99539542ca | ||
|
|
6e8f810cbd | ||
|
|
c7294ddaba | ||
|
|
cb82637b8e | ||
|
|
aa20ac9a18 | ||
|
|
53464ab25c | ||
|
|
1e0e883c6e | ||
|
|
d14afc5720 | ||
|
|
24706388c3 | ||
|
|
df9e36928c | ||
|
|
c245ccb55b | ||
|
|
ba348eb639 | ||
|
|
f6ce39f7c1 | ||
|
|
4732a97611 | ||
|
|
0fe29c19b1 | ||
|
|
7da42c9e25 | ||
|
|
e490769044 |
+50
-19
@@ -16,31 +16,61 @@ DEFAULT_CURRENCY_SYMBOL="RUB" # e.g., RUB, USD, EUR
|
||||
SUPPORT_LINK=https://t.me/your_support_link
|
||||
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service
|
||||
TERMS_OF_SERVICE_URL=https://example.com/tos
|
||||
SUBSCRIPTION_MINI_APP_URL=
|
||||
START_COMMAND_DESCRIPTION=
|
||||
|
||||
# Webhook Base URL (used for Telegram and payment providers)
|
||||
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
|
||||
# YooKassa Payment Gateway Configuration
|
||||
YOOKASSA_SHOP_ID=your_shop_id
|
||||
YOOKASSA_SECRET_KEY=your_secret_key
|
||||
YOOKASSA_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
YOOKASSA_RETURN_URL=https://t.me/your_bot
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com
|
||||
YOOKASSA_VAT_CODE=1
|
||||
YOOKASSA_PAYMENT_MODE=full_prepayment
|
||||
YOOKASSA_PAYMENT_SUBJECT=payment
|
||||
|
||||
# Telegram Webhook Configuration (Optional)
|
||||
# If unset, bot will use polling while YooKassa uses webhooks.
|
||||
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
# CryptoBot Payment Gateway Configuration
|
||||
CRYPTOPAY_TOKEN=
|
||||
CRYPTOPAY_NETWORK=mainnet
|
||||
CRYPTOPAY_CURRENCY_TYPE=fiat
|
||||
CRYPTOPAY_ASSET=RUB
|
||||
# API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_API_KEY=
|
||||
|
||||
# Subscription Prices (integer values)
|
||||
PRICE_1_MONTH=150
|
||||
PRICE_3_MONTHS=300
|
||||
PRICE_6_MONTHS=500
|
||||
PRICE_12_MONTHS=900
|
||||
# Payment Method Toggles
|
||||
YOOKASSA_ENABLED=True
|
||||
STARS_ENABLED=True
|
||||
TRIBUTE_ENABLED=True
|
||||
CRYPTOPAY_ENABLED=True
|
||||
|
||||
# Subscription Expiration Notifications
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7
|
||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC=13
|
||||
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC=0
|
||||
# Subscription Options
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
TRIBUTE_LINK_1_MONTH=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
TRIBUTE_LINK_3_MONTHS=
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
TRIBUTE_LINK_6_MONTHS=
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
|
||||
# Subscription Notifications
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED=True
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE=True
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3
|
||||
|
||||
# Referral Bonus Days
|
||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||
@@ -55,14 +85,15 @@ REFEREE_BONUS_DAYS_12_MONTHS=15
|
||||
# Panel API Configuration
|
||||
PANEL_API_URL=http://your_panel_api_url/api
|
||||
PANEL_API_KEY=your_panel_api_key
|
||||
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
|
||||
|
||||
# Default settings for NEW panel users
|
||||
PANEL_USER_DEFAULT_EXPIRE_DAYS=1
|
||||
PANEL_USER_DEFAULT_TRAFFIC_BYTES=0
|
||||
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY="NO_RESET"
|
||||
# User traffic limits (applied for all users)
|
||||
# 0 means unlimited
|
||||
USER_TRAFFIC_LIMIT_GB=0
|
||||
USER_TRAFFIC_STRATEGY="NO_RESET"
|
||||
|
||||
# Default Inbounds for Panel Users (Optional, comma-separated UUIDs)
|
||||
PANEL_USER_DEFAULT_INBOUND_UUIDS=uuid1,uuid2,uuid3
|
||||
# Default Inbounds for Users (Optional, comma-separated UUIDs)
|
||||
USER_INBOUND_UUIDS=uuid1,uuid2,uuid3
|
||||
|
||||
# Trial Settings
|
||||
TRIAL_ENABLED=True
|
||||
|
||||
+11
-2
@@ -1,11 +1,20 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN rm -rf /root/.cache
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -15,6 +15,7 @@ This Telegram bot is designed to automate the sale and management of subscriptio
|
||||
* **Subscription Management:**
|
||||
* Handles subscription purchases for various periods (1, 3, 6, 12 months).
|
||||
* Integrates with **YooKassa** for payment processing, including fiscal receipt data.
|
||||
* Supports **Crypto Pay** for payments with fiat currency (RUB by default).
|
||||
* Automatic subscription activation/extension upon successful payment.
|
||||
* Link and syncs users with a **Remnawave panel** account, primarily matching by Telegram ID.
|
||||
* Updates user status, expiration dates, traffic limits, and inbounds on the Remnawave panel.
|
||||
@@ -47,7 +48,7 @@ This Telegram bot is designed to automate the sale and management of subscriptio
|
||||
* **Python 3.11**
|
||||
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
|
||||
* **aiohttp:** For running the webhook server
|
||||
* **aiosqlite:** Asynchronous SQLite database interaction
|
||||
* **sqlalchemy:** Asynchronous PostgreSQL database interaction
|
||||
* **YooKassa SDK:** For payment processing
|
||||
* **APScheduler:** For scheduled tasks (e.g., notifications)
|
||||
* **Pydantic:** For settings management (loading from `.env`)
|
||||
@@ -83,46 +84,55 @@ This Telegram bot is designed to automate the sale and management of subscriptio
|
||||
* `DEFAULT_CURRENCY_SYMBOL`: e.g., `RUB`, `USD`.
|
||||
* `SUPPORT_LINK`: (Optional) URL for a support chat/contact (e.g., `https://t.me/your_support`).
|
||||
* `SERVER_STATUS_URL`: (Optional) URL to a server status page (e.g., Uptime Kuma).
|
||||
* `SUBSCRIPTION_MINI_APP_URL`: (Optional) URL of the Telegram mini app for viewing subscription details. If set, the "My Subscription" button will open this mini app and the bot will register it automatically via API.
|
||||
* `START_COMMAND_DESCRIPTION`: (Optional) Description for the `/start` command shown in the bot's menu.
|
||||
* **YooKassa Settings:**
|
||||
* `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa.
|
||||
* `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa.
|
||||
* `YOOKASSA_WEBHOOK_BASE_URL`: Your publicly accessible HTTPS base URL where the bot will listen for YooKassa webhooks (e.g., `https://your.domain.com`). The full path will be `{YOOKASSA_WEBHOOK_BASE_URL}/webhook/yookassa`.
|
||||
* `WEBHOOK_BASE_URL`: Base URL for all webhooks (Telegram, YooKassa, Crypto Pay). Example: `https://webhooks.yourdomain.com`.
|
||||
* `YOOKASSA_RETURN_URL`: (Optional) URL user is redirected to after payment, often `https://t.me/your_bot_username`.
|
||||
* `YOOKASSA_DEFAULT_RECEIPT_EMAIL`: **Important for 54-FZ (Russian fiscalization).** A default email for sending fiscal receipts.
|
||||
* `YOOKASSA_VAT_CODE`: VAT code for items in receipt (e.g., `1` for "No VAT". Consult YooKassa documentation and tax advisor).
|
||||
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
|
||||
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
|
||||
* `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates.
|
||||
* `PRICE_X_MONTH`: Prices for different subscription durations.
|
||||
* **Crypto Pay Settings:** `CRYPTOPAY_TOKEN`, `CRYPTOPAY_NETWORK` (`mainnet` or `testnet`), `CRYPTOPAY_CURRENCY_TYPE` (`fiat` or `crypto`), `CRYPTOPAY_ASSET` (e.g., `RUB`). Enable with `CRYPTOPAY_ENABLED`.
|
||||
* **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`, `CRYPTOPAY_ENABLED`.
|
||||
* **Subscription Options:** For each duration you can use variables like
|
||||
`1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH`
|
||||
(and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`).
|
||||
* **Panel API Settings:**
|
||||
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://localhost:3000/api` or `https://panel.yourdomain.com/api`).
|
||||
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`).
|
||||
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
|
||||
* `PANEL_USER_DEFAULT_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users.
|
||||
* `PANEL_WEBHOOK_SECRET`: Secret key for verifying webhooks from the Remnawave panel.
|
||||
* `USER_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users.
|
||||
* `USER_TRAFFIC_LIMIT_GB` and `USER_TRAFFIC_STRATEGY`: Default traffic limit in gigabytes (0 for unlimited) and the reset strategy applied when updating users on the panel.
|
||||
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
|
||||
* `WEB_SERVER_HOST`, `WEB_SERVER_PORT`: Host and port for the bot's internal webhook server.
|
||||
* `LOGS_PAGE_SIZE`: For admin panel log pagination.
|
||||
|
||||
3. **Locales:**
|
||||
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. The `bot_database.sqlite3` and `locales` directory will be mounted as volumes in Docker. `locales` mounting is optional.
|
||||
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional.
|
||||
|
||||
4. **Build and Run with Docker Compose:**
|
||||
4. **Run with Docker Compose:**
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
docker compose up -d
|
||||
```
|
||||
This command will build the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `vpn-shop` service in detached mode.
|
||||
This command will pull the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `remnawave-tg-shop` service in detached mode.
|
||||
|
||||
5. **Webhook Setup (Important if using webhooks):**
|
||||
* **Reverse Proxy (Nginx, Caddy, etc.):** You need a reverse proxy to handle incoming HTTPS traffic, manage SSL certificates (e.g., from Let's Encrypt), and forward requests to your bot's container.
|
||||
* Forward requests for `https://{YOOKASSA_WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://vpn-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `vpn-shop` is the service name in `docker-compose.yml`).
|
||||
* If using Telegram webhooks, forward requests for `https://{TELEGRAM_WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://vpn-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
|
||||
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `TELEGRAM_WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`.
|
||||
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `remnawave-tg-shop` is the service name in `docker-compose.yml`).
|
||||
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/cryptopay` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/cryptopay`.
|
||||
* Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/tribute` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/tribute`.
|
||||
* If using Telegram webhooks, forward requests for `https://{WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
|
||||
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`.
|
||||
|
||||
6. **Database:**
|
||||
* A SQLite database file (`bot_database.sqlite3`) will be created in your project root (or wherever you map the volume). The schema is initialized automatically on the first run if the file doesn't exist.
|
||||
* A PostgreSQL database will be created in the docker container. The schema is initialized automatically on the first run if the database doesn't exist.
|
||||
|
||||
7. **Viewing Logs:**
|
||||
```bash
|
||||
docker compose logs -f vpn-shop
|
||||
docker compose logs -f remnawave-tg-shop
|
||||
```
|
||||
|
||||
## 🐳 Docker Setup
|
||||
@@ -154,31 +164,39 @@ CMD ["python", "main.py"]
|
||||
```
|
||||
|
||||
### `docker-compose.yml`
|
||||
|
||||
YAML
|
||||
|
||||
```
|
||||
services:
|
||||
vpn-shop:
|
||||
build: .
|
||||
container_name: vpn-shop
|
||||
hostname: vpn-shop
|
||||
networks:
|
||||
- remnawave-network # Ensure this external network exists or define it
|
||||
volumes:
|
||||
- ./bot_database.sqlite3:/app/bot_database.sqlite3
|
||||
# - ./locales:/app/locales
|
||||
remnawave-tg-shop:
|
||||
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||
# build: .
|
||||
container_name: remnawave-tg-shop
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
# volumes:
|
||||
# - ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
# Optionally, expose ports if you are not using a shared Docker network
|
||||
# and want to access the bot's webserver directly (not recommended for production without a reverse proxy)
|
||||
# ports:
|
||||
# - "8080:8080"
|
||||
|
||||
networks:
|
||||
remnawave-network:
|
||||
external: true # Assumes 'remnawave-network' is an existing external Docker network
|
||||
# If not, you might want to define it here or use a default bridge.
|
||||
postgres:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
restart: unless-stopped
|
||||
|
||||
# networks:
|
||||
# remnawave-network:
|
||||
# external: true
|
||||
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
name: remnawave-tg-shop-db-data
|
||||
```
|
||||
|
||||
**Note on `remnawave-network`:** The `docker-compose.yml` assumes an external network named `remnawave-network`. If this network doesn't exist or you want the bot on a different network (e.g., a default bridge or a new one defined in this compose file), you'll need to adjust the `networks` section. If the Remnawave panel is also running in Docker on the same host, putting them on the same user-defined network allows them to communicate using service names.
|
||||
@@ -196,17 +214,21 @@ networks:
|
||||
│ ├── states/ # FSM states
|
||||
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
|
||||
├── config/
|
||||
│ └── settings.py # Pydantic settings model
|
||||
│ └── settings.py # Pydantic settings and config parser
|
||||
├── db/
|
||||
│ └── database.py # Database schema, connection, and CRUD functions
|
||||
├── locales/ # Localization files (en.json, ru.json)
|
||||
├── .env.example # Example environment variables
|
||||
├── .env # Your local environment variables (ignored by git)
|
||||
├── Dockerfile # Instructions to build the Docker image
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── requirements.txt # Python dependencies
|
||||
└── main.py # Main entry point to run the bot
|
||||
|
||||
│ ├── dal/ # Data Access Layer (queries, transactions)
|
||||
│ ├── database_setup.py # DB connection/init setup
|
||||
│ └── models.py # ORM models (e.g., SQLAlchemy)
|
||||
├── locales/ # Localization files
|
||||
│ ├── en.json # English locale
|
||||
│ └── ru.json # Russian locale
|
||||
├── .env.example # Example environment variables for local setup
|
||||
├── .env # Actual environment variables (ignored by Git)
|
||||
├── Dockerfile # Docker image build instructions
|
||||
├── docker-compose.yml # Docker Compose orchestration config
|
||||
├── requirements.txt # List of Python dependencies
|
||||
├── README.md # Project documentation
|
||||
└── main.py # Entry point to launch the bot
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
+113
-88
@@ -11,16 +11,23 @@ from config.settings import Settings
|
||||
from db.dal import user_dal, message_log_dal
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_broadcast_confirmation_keyboard,
|
||||
get_back_to_admin_panel_keyboard,
|
||||
get_admin_panel_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_broadcast_router")
|
||||
|
||||
|
||||
async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
async def broadcast_message_prompt_handler(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -35,25 +42,28 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for broadcast prompt: {e}. Sending new."
|
||||
)
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
|
||||
async def process_broadcast_message_handler(message: types.Message,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
async def process_broadcast_message_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -63,28 +73,35 @@ async def process_broadcast_message_handler(message: types.Message,
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
broadcast_message_text = message.html_text
|
||||
await state.update_data(broadcast_message=broadcast_message_text)
|
||||
# Сохраняем в state исходный текст и entities
|
||||
text = message.text or message.caption or ""
|
||||
entities = message.entities or message.caption_entities or []
|
||||
|
||||
preview_snippet = (
|
||||
broadcast_message_text[:200] +
|
||||
"...") if len(broadcast_message_text) > 200 else broadcast_message_text
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt",
|
||||
message_preview=preview_snippet)
|
||||
await state.update_data(
|
||||
broadcast_text=text,
|
||||
broadcast_entities=entities,
|
||||
)
|
||||
|
||||
await message.answer(confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(
|
||||
current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
preview_snippet = (text[:200] + "...") if len(text) > 200 else text
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
|
||||
|
||||
await message.answer(
|
||||
confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n),
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:main",
|
||||
AdminStates.waiting_for_broadcast_message)
|
||||
async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
|
||||
)
|
||||
async def cancel_broadcast_at_prompt_stage(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
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")
|
||||
if not i18n or not callback.message:
|
||||
@@ -94,96 +111,104 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
|
||||
_("admin_broadcast_cancelled_nav_back"), reply_markup=None
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
|
||||
|
||||
await callback.answer(_("admin_broadcast_cancelled_alert"))
|
||||
await state.clear()
|
||||
|
||||
await callback.message.answer(_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
await callback.message.answer(
|
||||
_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("broadcast_final_action:"),
|
||||
AdminStates.confirming_broadcast)
|
||||
async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict, bot: Bot,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
@router.callback_query(
|
||||
F.data.startswith("broadcast_final_action:"),
|
||||
AdminStates.confirming_broadcast,
|
||||
)
|
||||
async def confirm_broadcast_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
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 processing broadcast confirmation.",
|
||||
show_alert=True)
|
||||
await callback.answer("Error processing broadcast confirmation.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
action = callback.data.split(":")[1]
|
||||
user_fsm_data = await state.get_data()
|
||||
broadcast_message_to_send = user_fsm_data.get("broadcast_message")
|
||||
|
||||
if action == "send":
|
||||
if not broadcast_message_to_send:
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_error_no_message"))
|
||||
text = user_fsm_data.get("broadcast_text")
|
||||
entities = user_fsm_data.get("broadcast_entities", [])
|
||||
|
||||
if not text:
|
||||
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_broadcast_error_no_message_alert"),
|
||||
show_alert=True)
|
||||
await callback.answer(
|
||||
_("admin_broadcast_error_no_message_alert"), show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"),
|
||||
reply_markup=None)
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
|
||||
await callback.answer()
|
||||
|
||||
user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast(
|
||||
session)
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
logging.info(
|
||||
f"Admin {callback.from_user.id} starting broadcast: '{broadcast_message_to_send[:50]}...' to {len(user_ids_for_broadcast)} users."
|
||||
)
|
||||
admin_user = callback.from_user
|
||||
logging.info(
|
||||
f"Admin {admin_user.id} broadcasting '{text[:50]}...' to {len(user_ids)} users."
|
||||
)
|
||||
|
||||
for user_id_to_send in user_ids_for_broadcast:
|
||||
for uid in user_ids:
|
||||
try:
|
||||
await bot.send_message(user_id_to_send,
|
||||
broadcast_message_to_send,
|
||||
parse_mode="HTML")
|
||||
await bot.send_message(
|
||||
chat_id=uid,
|
||||
text=text,
|
||||
entities=entities,
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
log_payload_sent = {
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_sent",
|
||||
"content":
|
||||
f"To user {user_id_to_send}: {broadcast_message_to_send[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user_id_to_send
|
||||
}
|
||||
await message_log_dal.create_message_log(
|
||||
session, log_payload_sent)
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_sent",
|
||||
"content": f"To user {uid}: {text[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logging.warning(
|
||||
f"Failed to send broadcast to user {user_id_to_send}: {type(e).__name__} - {e}"
|
||||
f"Failed to send broadcast to {uid}: {type(e).__name__} – {e}"
|
||||
)
|
||||
log_payload_failed = {
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_failed",
|
||||
"content":
|
||||
f"For user {user_id_to_send}: {type(e).__name__} - {str(e)[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user_id_to_send
|
||||
}
|
||||
await message_log_dal.create_message_log(
|
||||
session, log_payload_failed)
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_failed",
|
||||
"content": f"For user {uid}: {type(e).__name__} – {str(e)[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
try:
|
||||
@@ -192,17 +217,17 @@ async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
|
||||
await session.rollback()
|
||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||
|
||||
result_message = _("admin_broadcast_finished_stats",
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count)
|
||||
result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count)
|
||||
await callback.message.answer(
|
||||
result_message,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
|
||||
elif action == "cancel":
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -24,6 +24,7 @@ router = Router(name="admin_common_router")
|
||||
@router.message(Command("admin"))
|
||||
async def admin_panel_command_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
):
|
||||
@@ -34,6 +35,7 @@ async def admin_panel_command_handler(
|
||||
await message.answer("Language service error.")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
await message.answer(_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
@@ -73,6 +75,9 @@ async def admin_panel_actions_callback_handler(
|
||||
elif action == "create_promo":
|
||||
await admin_promo_handlers.create_promo_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "manage_promos":
|
||||
await admin_promo_handlers.manage_promo_codes_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "view_promos":
|
||||
await admin_promo_handlers.view_promo_codes_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
@@ -11,6 +12,7 @@ from db.dal import promo_code_dal
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_promo_codes_router")
|
||||
@@ -194,8 +196,152 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:main",
|
||||
AdminStates.waiting_for_promo_details)
|
||||
@router.callback_query(F.data == "admin_action:manage_promos")
|
||||
async def manage_promo_codes_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
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 displaying promo codes.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
promo_models = await promo_code_dal.get_all_active_promo_codes(session,
|
||||
limit=20,
|
||||
offset=0)
|
||||
if not promo_models:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_active_promos"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
for promo in promo_models:
|
||||
kb.row(
|
||||
InlineKeyboardButton(
|
||||
text=promo.code,
|
||||
callback_data=f"promo_edit:{promo.promo_code_id}"),
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_delete_button"),
|
||||
callback_data=f"promo_delete:{promo.promo_code_id}"),
|
||||
)
|
||||
kb.row(
|
||||
InlineKeyboardButton(text=_("back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main"))
|
||||
|
||||
await callback.message.edit_text(
|
||||
_("admin_manage_promos_title"),
|
||||
reply_markup=kb.as_markup())
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_edit:"))
|
||||
async def promo_edit_select_handler(callback: types.CallbackQuery, state: FSMContext,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not promo or not callback.message:
|
||||
await callback.answer("Error", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await state.update_data(edit_promo_id=promo_id)
|
||||
await state.set_state(AdminStates.waiting_for_promo_edit_details)
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_edit_prompt", code=promo.code),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_promo_edit_details, F.text)
|
||||
async def process_promo_edit_details(message: types.Message, state: FSMContext,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get("edit_promo_id")
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not promo_id:
|
||||
await message.answer("Error")
|
||||
await state.clear()
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
parts = message.text.strip().split()
|
||||
if not (3 <= len(parts) <= 4):
|
||||
await message.answer(_("admin_promo_invalid_format"))
|
||||
return
|
||||
try:
|
||||
code_str = parts[0].upper()
|
||||
bonus = int(parts[1])
|
||||
max_act = int(parts[2])
|
||||
valid_until = None
|
||||
if len(parts) == 4:
|
||||
days = int(parts[3])
|
||||
valid_until = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
except Exception:
|
||||
await message.answer(_("admin_promo_invalid_format_general"))
|
||||
return
|
||||
|
||||
update_data = {
|
||||
"code": code_str,
|
||||
"bonus_days": bonus,
|
||||
"max_activations": max_act,
|
||||
"valid_until": valid_until,
|
||||
}
|
||||
updated = await promo_code_dal.update_promo_code(session, promo_id, update_data)
|
||||
if updated:
|
||||
await session.commit()
|
||||
await message.answer(
|
||||
_("admin_promo_updated_success", code=code_str),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
await message.answer(_("admin_promo_not_found"))
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_delete:"))
|
||||
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.delete_promo_code(session, promo_id)
|
||||
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", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if promo:
|
||||
await session.commit()
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_deleted_success", code=promo.code),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
else:
|
||||
await session.rollback()
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_not_found"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main",
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_promo_details,
|
||||
AdminStates.waiting_for_promo_edit_details,
|
||||
),
|
||||
)
|
||||
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
|
||||
+142
-102
@@ -16,11 +16,14 @@ router = Router(name="admin_sync_router")
|
||||
|
||||
|
||||
@router.message(Command("sync"))
|
||||
async def sync_command_handler(message_event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
bot: Bot, settings: Settings, i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
async def sync_command_handler(
|
||||
message_event: Union[types.Message, types.CallbackQuery],
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -33,14 +36,15 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
target_chat_id = message_event.chat.id if isinstance(
|
||||
message_event, types.Message) else (
|
||||
message_event.message.chat.id if message_event.message else None)
|
||||
target_chat_id = (
|
||||
message_event.chat.id
|
||||
if isinstance(message_event, types.Message)
|
||||
else (message_event.message.chat.id if message_event.message else None)
|
||||
)
|
||||
if not target_chat_id:
|
||||
logging.error("Sync handler: could not determine target_chat_id.")
|
||||
if isinstance(message_event, types.CallbackQuery):
|
||||
await message_event.answer("Error initiating sync.",
|
||||
show_alert=True)
|
||||
await message_event.answer("Error initiating sync.", show_alert=True)
|
||||
return
|
||||
|
||||
if isinstance(message_event, types.Message):
|
||||
@@ -59,21 +63,21 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
if panel_users_data is None:
|
||||
error_msg = "Failed to fetch users from panel or panel API issue."
|
||||
sync_errors.append(error_msg)
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_msg)
|
||||
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
|
||||
await session.commit()
|
||||
await bot.send_message(target_chat_id,
|
||||
_("sync_failed", details=error_msg))
|
||||
await bot.send_message(target_chat_id, _("sync_failed", details=error_msg))
|
||||
return
|
||||
|
||||
if not panel_users_data:
|
||||
status_msg = "No users found in the panel to sync."
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "success", status_msg, 0, 0)
|
||||
session, "success", status_msg, 0, 0
|
||||
)
|
||||
await session.commit()
|
||||
await bot.send_message(
|
||||
target_chat_id,
|
||||
_("sync_completed", status="Success", details=status_msg))
|
||||
_("sync_completed", status="Success", details=status_msg),
|
||||
)
|
||||
return
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
@@ -81,9 +85,9 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
|
||||
for panel_user_dict in panel_users_data:
|
||||
users_processed_count += 1
|
||||
panel_uuid = panel_user_dict.get('uuid')
|
||||
telegram_id_from_panel_str = panel_user_dict.get('telegramId')
|
||||
panel_username = panel_user_dict.get('username')
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
telegram_id_from_panel_str = panel_user_dict.get("telegramId")
|
||||
panel_username = panel_user_dict.get("username")
|
||||
|
||||
if not panel_uuid:
|
||||
logging.warning(
|
||||
@@ -111,26 +115,22 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
|
||||
continue
|
||||
|
||||
bot_user = await user_dal.get_user_by_id(session,
|
||||
telegram_id_from_panel)
|
||||
bot_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||
if not bot_user:
|
||||
user_data_to_create = {
|
||||
"user_id":
|
||||
telegram_id_from_panel,
|
||||
"username":
|
||||
panel_username,
|
||||
"panel_user_uuid":
|
||||
panel_uuid,
|
||||
"language_code":
|
||||
settings.DEFAULT_LANGUAGE,
|
||||
"registration_date":
|
||||
datetime.fromisoformat(
|
||||
panel_user_dict['createdAt'].replace("Z", "+00:00"))
|
||||
if panel_user_dict.get('createdAt') else datetime.now(
|
||||
timezone.utc)
|
||||
"user_id": telegram_id_from_panel,
|
||||
"username": panel_username,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"language_code": settings.DEFAULT_LANGUAGE,
|
||||
"registration_date": (
|
||||
datetime.fromisoformat(
|
||||
panel_user_dict["createdAt"].replace("Z", "+00:00")
|
||||
)
|
||||
if panel_user_dict.get("createdAt")
|
||||
else datetime.now(timezone.utc)
|
||||
),
|
||||
}
|
||||
bot_user = await user_dal.create_user(session,
|
||||
user_data_to_create)
|
||||
bot_user = await user_dal.create_user(session, user_data_to_create)
|
||||
logging.info(
|
||||
f"Sync: Created new local user {telegram_id_from_panel} from panel data {panel_uuid}."
|
||||
)
|
||||
@@ -142,8 +142,12 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
)
|
||||
|
||||
conflicting_user = await user_dal.get_user_by_panel_uuid(
|
||||
session, panel_uuid)
|
||||
if conflicting_user and conflicting_user.user_id != telegram_id_from_panel:
|
||||
session, panel_uuid
|
||||
)
|
||||
if (
|
||||
conflicting_user
|
||||
and conflicting_user.user_id != telegram_id_from_panel
|
||||
):
|
||||
sync_errors.append(
|
||||
f"Panel UUID {panel_uuid} for TG {telegram_id_from_panel} already linked to another TG user {conflicting_user.user_id}."
|
||||
)
|
||||
@@ -151,60 +155,64 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
continue
|
||||
|
||||
await user_dal.update_user(
|
||||
session, telegram_id_from_panel, {
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"username": panel_username
|
||||
})
|
||||
session,
|
||||
telegram_id_from_panel,
|
||||
{"panel_user_uuid": panel_uuid, "username": panel_username},
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Updated panel_uuid for local user {telegram_id_from_panel} to {panel_uuid}."
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_dict.get(
|
||||
'subscriptionUuid') or panel_user_dict.get('shortUuid')
|
||||
"subscriptionUuid"
|
||||
) or panel_user_dict.get("shortUuid")
|
||||
if panel_sub_link_id:
|
||||
end_date_str = panel_user_dict.get('expireAt')
|
||||
start_date_str = panel_user_dict.get('createdAt')
|
||||
end_date_str = panel_user_dict.get("expireAt")
|
||||
start_date_str = panel_user_dict.get("createdAt")
|
||||
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date_obj = datetime.fromisoformat(
|
||||
end_date_str.replace("Z", "+00:00"))
|
||||
start_date_obj = datetime.fromisoformat(
|
||||
start_date_str.replace(
|
||||
"Z",
|
||||
"+00:00")) if start_date_str else datetime.now(
|
||||
timezone.utc)
|
||||
end_date_str.replace("Z", "+00:00")
|
||||
)
|
||||
start_date_obj = (
|
||||
datetime.fromisoformat(
|
||||
start_date_str.replace("Z", "+00:00")
|
||||
)
|
||||
if start_date_str
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
status_from_panel = panel_user_dict.get(
|
||||
'status', 'UNKNOWN').upper()
|
||||
is_active_flag = 1 if status_from_panel == 'ACTIVE' and end_date_obj > datetime.now(
|
||||
timezone.utc) else 0
|
||||
"status", "UNKNOWN"
|
||||
).upper()
|
||||
is_active_flag = (
|
||||
1
|
||||
if status_from_panel == "ACTIVE"
|
||||
and end_date_obj > datetime.now(timezone.utc)
|
||||
else 0
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id":
|
||||
telegram_id_from_panel,
|
||||
"panel_user_uuid":
|
||||
panel_uuid,
|
||||
"panel_subscription_uuid":
|
||||
panel_sub_link_id,
|
||||
"start_date":
|
||||
start_date_obj,
|
||||
"end_date":
|
||||
end_date_obj,
|
||||
"is_active":
|
||||
is_active_flag,
|
||||
"status_from_panel":
|
||||
status_from_panel,
|
||||
"traffic_limit_bytes":
|
||||
panel_user_dict.get('trafficLimitBytes'),
|
||||
"traffic_used_bytes":
|
||||
panel_user_dict.get('usedTrafficBytes')
|
||||
"user_id": telegram_id_from_panel,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date_obj,
|
||||
"end_date": end_date_obj,
|
||||
"is_active": is_active_flag,
|
||||
"status_from_panel": status_from_panel,
|
||||
"traffic_limit_bytes": panel_user_dict.get(
|
||||
"trafficLimitBytes"
|
||||
),
|
||||
"traffic_used_bytes": panel_user_dict.get(
|
||||
"usedTrafficBytes"
|
||||
),
|
||||
}
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_link_id)
|
||||
await subscription_dal.upsert_subscription(
|
||||
session, sub_payload)
|
||||
session, panel_uuid, panel_sub_link_id
|
||||
)
|
||||
await subscription_dal.upsert_subscription(session, sub_payload)
|
||||
subscriptions_synced_count += 1
|
||||
users_synced_successfully += 1
|
||||
except ValueError as e_date:
|
||||
@@ -217,7 +225,8 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
except Exception as e_sub_sync:
|
||||
logging.error(
|
||||
f"Sync: Error syncing subscription for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}): {e_sub_sync}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Sub sync error for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
|
||||
)
|
||||
@@ -228,7 +237,8 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
else:
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, None)
|
||||
session, panel_uuid, None
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has no subscription link on panel. Deactivated local subs if any."
|
||||
)
|
||||
@@ -239,6 +249,17 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
f"Sync progress: {users_processed_count}/{total_panel_users} users processed from panel."
|
||||
)
|
||||
|
||||
panel_uuid_set = {u.get("uuid") for u in panel_users_data if u.get("uuid")}
|
||||
local_users_with_uuid = await user_dal.get_all_users_with_panel_uuid(session)
|
||||
for local_user in local_users_with_uuid:
|
||||
if local_user.panel_user_uuid not in panel_uuid_set:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, local_user.panel_user_uuid, None
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Local user {local_user.user_id} with panel UUID {local_user.panel_user_uuid} not found on panel. Deactivated local subs."
|
||||
)
|
||||
|
||||
status_msg_key = "sync_completed_details"
|
||||
final_status_type = "success"
|
||||
|
||||
@@ -251,36 +272,51 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
details_for_db = f"Successfully processed {users_processed_count} users. Synced {subscriptions_synced_count} subscriptions."
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, final_status_type, details_for_db, users_processed_count,
|
||||
subscriptions_synced_count)
|
||||
session,
|
||||
final_status_type,
|
||||
details_for_db,
|
||||
users_processed_count,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
final_user_message = _(status_msg_key,
|
||||
total_checked=total_panel_users,
|
||||
users_synced=users_synced_successfully,
|
||||
subs_synced=subscriptions_synced_count,
|
||||
errors_count=len(sync_errors),
|
||||
error_details_preview=error_preview
|
||||
if sync_errors else _("no_errors_placeholder"))
|
||||
final_user_message = _(
|
||||
status_msg_key,
|
||||
total_checked=total_panel_users,
|
||||
users_synced=users_synced_successfully,
|
||||
subs_synced=subscriptions_synced_count,
|
||||
errors_count=len(sync_errors),
|
||||
error_details_preview=(
|
||||
error_preview if sync_errors else _("no_errors_placeholder")
|
||||
),
|
||||
)
|
||||
await bot.send_message(target_chat_id, final_user_message)
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(f"Global error during /sync command: {e_sync_global}",
|
||||
exc_info=True)
|
||||
error_detail_for_db = f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}"
|
||||
logging.error(
|
||||
f"Global error during /sync command: {e_sync_global}", exc_info=True
|
||||
)
|
||||
error_detail_for_db = (
|
||||
f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}"
|
||||
)
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_detail_for_db, users_processed_count,
|
||||
subscriptions_synced_count)
|
||||
session,
|
||||
"failed",
|
||||
error_detail_for_db,
|
||||
users_processed_count,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
|
||||
await bot.send_message(target_chat_id,
|
||||
_("sync_failed", details=error_detail_for_db))
|
||||
await bot.send_message(
|
||||
target_chat_id, _("sync_failed", details=error_detail_for_db)
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("syncstatus"))
|
||||
async def sync_status_command_handler(message: types.Message, i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
async def sync_status_command_handler(
|
||||
message: types.Message, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -292,13 +328,16 @@ async def sync_status_command_handler(message: types.Message, i18n_data: dict,
|
||||
response_text = ""
|
||||
if status_record_model:
|
||||
last_time_val = status_record_model.last_sync_time
|
||||
last_time_str = last_time_val.strftime(
|
||||
'%Y-%m-%d %H:%M:%S UTC') if last_time_val else "N/A"
|
||||
last_time_str = (
|
||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
|
||||
)
|
||||
|
||||
details_val = status_record_model.details
|
||||
details_str = (details_val[:200] +
|
||||
"...") if details_val and len(details_val) > 200 else (
|
||||
details_val or "N/A")
|
||||
details_str = (
|
||||
(details_val[:200] + "...")
|
||||
if details_val and len(details_val) > 200
|
||||
else (details_val or "N/A")
|
||||
)
|
||||
|
||||
response_text = (
|
||||
f"<b>{_('admin_stats_last_sync_header')}</b>\n"
|
||||
@@ -306,7 +345,8 @@ async def sync_status_command_handler(message: types.Message, i18n_data: dict,
|
||||
f" {_('admin_stats_sync_status')}: {status_record_model.status}\n"
|
||||
f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n"
|
||||
f" {_('admin_stats_sync_subs_synced')}: {status_record_model.subscriptions_synced}\n"
|
||||
f" {_('admin_stats_sync_details_label')}: {details_str}")
|
||||
f" {_('admin_stats_sync_details_label')}: {details_str}"
|
||||
)
|
||||
else:
|
||||
response_text = _("admin_sync_status_never_run")
|
||||
|
||||
|
||||
@@ -28,20 +28,16 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
|
||||
async def _get_user_model_by_input(session: AsyncSession,
|
||||
input_text: str) -> Optional[User]:
|
||||
|
||||
user_model: Optional[User] = None
|
||||
if input_text.isdigit():
|
||||
try:
|
||||
user_model = await user_dal.get_user_by_id(session,
|
||||
int(input_text))
|
||||
return await user_dal.get_user(session, user_id=int(input_text))
|
||||
except ValueError:
|
||||
pass
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_model = await user_dal.get_user_by_username(
|
||||
session, input_text[1:])
|
||||
elif USERNAME_REGEX.match(input_text):
|
||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
||||
return user_model
|
||||
return None
|
||||
if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
return await user_dal.get_user(session, username=input_text[1:])
|
||||
if USERNAME_REGEX.match(input_text):
|
||||
return await user_dal.get_user(session, username=input_text)
|
||||
return None
|
||||
|
||||
|
||||
async def ban_user_prompt_handler(callback: types.CallbackQuery,
|
||||
|
||||
@@ -17,9 +17,11 @@ from db.dal import payment_dal, user_dal
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.payment_service import YooKassaService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
from bot.services.notification_service import notify_admin_new_payment
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
|
||||
payment_processing_lock = asyncio.Lock()
|
||||
|
||||
@@ -105,7 +107,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
subscription_months,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id)
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa")
|
||||
|
||||
if not activation_details or not activation_details.get('end_date'):
|
||||
logging.error(
|
||||
@@ -132,7 +135,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
||||
|
||||
success_message = ""
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
if db_user and db_user.referred_by_id:
|
||||
@@ -143,37 +149,61 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
|
||||
success_message = _(
|
||||
"payment_successful_with_referral_bonus",
|
||||
details_message = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=subscription_months,
|
||||
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_referee_bonus_days_from_referral,
|
||||
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display)
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
|
||||
success_message = _(
|
||||
"payment_successful_with_promo",
|
||||
details_message = _(
|
||||
"payment_successful_with_promo_full",
|
||||
months=subscription_months,
|
||||
bonus_days=applied_promo_bonus_days,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
elif final_end_date_for_user:
|
||||
success_message = _(
|
||||
"payment_successful",
|
||||
details_message = _(
|
||||
"payment_successful_full",
|
||||
months=subscription_months,
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
|
||||
end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
|
||||
)
|
||||
success_message = _("payment_successful_error_details")
|
||||
details_message = _("payment_successful_error_details")
|
||||
|
||||
details_markup = get_connect_and_main_keyboard(
|
||||
user_lang, i18n, settings, config_link
|
||||
)
|
||||
try:
|
||||
await bot.send_message(user_id, success_message)
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
details_message,
|
||||
reply_markup=details_markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_notify:
|
||||
logging.error(
|
||||
f"Failed to send final payment success message to user {user_id}: {e_notify}"
|
||||
f"Failed to send payment details message to user {user_id}: {e_notify}"
|
||||
)
|
||||
|
||||
await notify_admin_new_payment(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
user_id,
|
||||
subscription_months,
|
||||
payment_value,
|
||||
)
|
||||
|
||||
except Exception as e_process:
|
||||
logging.error(
|
||||
f"Error during process_successful_payment main try block for user {user_id}: {e_process}",
|
||||
|
||||
@@ -10,7 +10,11 @@ from config.settings import Settings
|
||||
from bot.states.user_states import UserPromoStates
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_back_to_main_menu_markup,
|
||||
get_connect_and_main_keyboard,
|
||||
)
|
||||
from datetime import datetime
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from .start import send_main_menu
|
||||
@@ -65,6 +69,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
|
||||
async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
promo_code_service: PromoCodeService,
|
||||
subscription_service: SubscriptionService,
|
||||
bot: Bot, session: AsyncSession):
|
||||
logging.info(
|
||||
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'"
|
||||
@@ -126,24 +131,42 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
|
||||
code=hcode(code_input.upper()))
|
||||
else:
|
||||
|
||||
success, response_text_from_service = await promo_code_service.apply_promo_code(
|
||||
success, result = await promo_code_service.apply_promo_code(
|
||||
session, user.id, code_input, current_lang)
|
||||
response_to_user_text = response_text_from_service
|
||||
if success:
|
||||
await session.commit()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' successfully applied for user {user.id}."
|
||||
)
|
||||
|
||||
new_end_date = result if isinstance(result, datetime) else None
|
||||
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")
|
||||
|
||||
response_to_user_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,
|
||||
)
|
||||
reply_markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, settings, config_link
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {response_text_from_service}"
|
||||
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
|
||||
)
|
||||
response_to_user_text = result
|
||||
reply_markup = get_back_to_main_menu_markup(
|
||||
current_lang, i18n
|
||||
)
|
||||
|
||||
await message.answer(response_to_user_text,
|
||||
reply_markup=get_back_to_main_menu_markup(
|
||||
current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
await message.answer(
|
||||
response_to_user_text,
|
||||
reply_markup=reply_markup,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await state.clear()
|
||||
logging.info(
|
||||
f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared."
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from aiogram.filters import CommandStart, Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from typing import Optional, Union
|
||||
@@ -30,7 +31,7 @@ async def send_main_menu(target_event: Union[types.Message,
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
user_id = target_event.from_user.id
|
||||
user_full_name = target_event.from_user.full_name
|
||||
user_full_name = hd.quote(target_event.from_user.full_name)
|
||||
|
||||
if not i18n:
|
||||
logging.error(
|
||||
@@ -192,7 +193,7 @@ async def start_command_handler(message: types.Message,
|
||||
f"Failed to update existing user {user_id} in session: {e_update}",
|
||||
exc_info=True)
|
||||
|
||||
await message.answer(_(key="welcome", user_name=user.full_name))
|
||||
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
|
||||
await send_main_menu(message,
|
||||
settings,
|
||||
i18n_data,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -8,11 +9,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscription_options_keyboard, get_confirm_subscription_keyboard,
|
||||
get_subscription_options_keyboard, get_payment_method_keyboard,
|
||||
get_payment_url_keyboard, get_back_to_main_menu_markup)
|
||||
from bot.services.payment_service import YooKassaService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.crypto_pay_service import CryptoPayService
|
||||
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
|
||||
|
||||
router = Router(name="user_subscription_router")
|
||||
@@ -98,28 +102,70 @@ async def select_subscription_period_callback_handler(
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
confirmation_text_content = get_text("confirm_subscription_prompt",
|
||||
months=months,
|
||||
price=f"{price_rub:.2f}",
|
||||
currency_symbol=currency_symbol_val)
|
||||
reply_markup = get_confirm_subscription_keyboard(months, price_rub,
|
||||
currency_symbol_val,
|
||||
current_lang, i18n)
|
||||
text_content = get_text("choose_payment_method")
|
||||
tribute_url = settings.tribute_payment_links.get(months)
|
||||
stars_price = settings.stars_subscription_options.get(months)
|
||||
reply_markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
tribute_url,
|
||||
stars_price,
|
||||
currency_symbol_val,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(confirmation_text_content,
|
||||
await callback.message.edit_text(text_content,
|
||||
reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for subscription confirmation failed: {e_edit}. Sending new one."
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(confirmation_text_content,
|
||||
await callback.message.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_sub:"))
|
||||
async def confirm_subscription_callback_handler(
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession, bot: Bot, stars_service: StarsService):
|
||||
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 i18n or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
stars_price = int(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_stars data in callback: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
payment_id = await stars_service.create_invoice(
|
||||
session, user_id, months, stars_price, payment_description)
|
||||
if payment_id is None:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
@@ -150,7 +196,7 @@ async def confirm_subscription_callback_handler(
|
||||
price_rub = float(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(
|
||||
f"Invalid confirmation data in callback: {callback.data}")
|
||||
f"Invalid pay_yk data in callback: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
@@ -248,6 +294,49 @@ async def confirm_subscription_callback_handler(
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_crypto:"))
|
||||
async def pay_crypto_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
cryptopay_service: CryptoPayService, session: AsyncSession):
|
||||
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 i18n or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
if not cryptopay_service or not cryptopay_service.configured:
|
||||
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
||||
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, amount_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
amount_val = float(amount_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_crypto data in callback: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
invoice_url = await cryptopay_service.create_invoice(
|
||||
session, user_id, months, amount_val, description)
|
||||
if invoice_url:
|
||||
await callback.message.edit_text(
|
||||
get_text("payment_link_message", months=months),
|
||||
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
|
||||
disable_web_page_preview=False,
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:subscribe")
|
||||
async def reshow_subscription_options_callback(callback: types.CallbackQuery,
|
||||
i18n_data: dict,
|
||||
@@ -257,97 +346,115 @@ async def reshow_subscription_options_callback(callback: types.CallbackQuery,
|
||||
|
||||
|
||||
async def my_subscription_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery], i18n_data: dict,
|
||||
settings: Settings, panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService, session: AsyncSession,
|
||||
bot: Bot):
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
user = event.from_user
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
|
||||
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: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||
|
||||
if not i18n or not target_message_obj:
|
||||
if not i18n or not target:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(get_text("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
if not panel_service or not subscription_service:
|
||||
logging.error(
|
||||
"PanelService or SubscriptionService is missing in my_subscription_command_handler."
|
||||
)
|
||||
await target_message_obj.answer(get_text("error_service_unavailable"))
|
||||
await target.answer(get_text("error_service_unavailable"))
|
||||
return
|
||||
|
||||
active_sub_details = await subscription_service.get_active_subscription_details(
|
||||
session, user.id)
|
||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||
|
||||
sub_info_text_content = ""
|
||||
if active_sub_details:
|
||||
end_date_obj = active_sub_details.get('end_date')
|
||||
days_left = 0
|
||||
if end_date_obj:
|
||||
if end_date_obj.tzinfo is None:
|
||||
end_date_obj = end_date_obj.replace(tzinfo=timezone.utc)
|
||||
days_left = (end_date_obj.date() - datetime.now().date()).days
|
||||
if not active:
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
actual_config_link = active_sub_details.get('config_link') or get_text(
|
||||
"config_link_not_available")
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"),
|
||||
callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
traffic_limit_bytes = active_sub_details.get('traffic_limit_bytes')
|
||||
traffic_used_bytes = active_sub_details.get('traffic_used_bytes')
|
||||
kb = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[buy_button],
|
||||
*back_markup.inline_keyboard
|
||||
]
|
||||
)
|
||||
|
||||
traffic_limit_gb_str = get_text("traffic_unlimited")
|
||||
if traffic_limit_bytes and traffic_limit_bytes > 0:
|
||||
traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB"
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=kb)
|
||||
except:
|
||||
await event.message.answer(text, reply_markup=kb)
|
||||
else:
|
||||
await event.answer(text, reply_markup=kb)
|
||||
return
|
||||
|
||||
traffic_used_gb_str = get_text("traffic_na")
|
||||
if traffic_used_bytes is not None:
|
||||
traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB"
|
||||
end_date = active.get("end_date")
|
||||
days_left = (
|
||||
(end_date.date() - datetime.now().date()).days
|
||||
if end_date else 0
|
||||
)
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(
|
||||
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_limit_bytes")
|
||||
else get_text("traffic_unlimited")
|
||||
),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_used_bytes") is not None
|
||||
else get_text("traffic_na")
|
||||
)
|
||||
)
|
||||
markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
sub_info_text_content = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date_obj.strftime("%Y-%m-%d")
|
||||
if end_date_obj else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active_sub_details.get(
|
||||
'status_from_panel', get_text('status_active')).capitalize(),
|
||||
config_link=actual_config_link,
|
||||
traffic_limit=traffic_limit_gb_str,
|
||||
traffic_used=traffic_used_gb_str)
|
||||
else:
|
||||
sub_info_text_content = get_text("subscription_not_active")
|
||||
logging.info(
|
||||
f"User {user.id} no active sub details for 'my_subscription'.")
|
||||
|
||||
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
if isinstance(event, types.CallbackQuery) and event.message:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
try:
|
||||
await event.message.edit_text(sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit 'my_subscription' failed: {e_edit}. Sending new message to chat {target_message_obj.chat.id}."
|
||||
)
|
||||
await bot.send_message(chat_id=target_message_obj.chat.id,
|
||||
text=sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except:
|
||||
await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
else:
|
||||
await target_message_obj.answer(sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def stars_successful_payment_handler(
|
||||
message: types.Message, settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession, stars_service: StarsService):
|
||||
sp = message.successful_payment
|
||||
if not sp or sp.currency != "XTR":
|
||||
return
|
||||
|
||||
payload = sp.invoice_payload or ""
|
||||
try:
|
||||
payment_id_str, months_str = payload.split(":")
|
||||
payment_db_id = int(payment_id_str)
|
||||
months = int(months_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid invoice payload for stars payment: {payload}")
|
||||
return
|
||||
|
||||
stars_amount = sp.total_amount
|
||||
await stars_service.process_successful_payment(
|
||||
session, message, payment_db_id, months, stars_amount, i18n_data)
|
||||
|
||||
|
||||
@router.message(Command("connect"))
|
||||
|
||||
@@ -7,7 +7,11 @@ from datetime import datetime
|
||||
from config.settings import Settings
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.keyboards.inline.user_keyboards import get_trial_confirmation_keyboard, get_main_menu_inline_keyboard
|
||||
from bot.services.notification_service import notify_admin_new_trial
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_trial_confirmation_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -15,13 +19,16 @@ router = Router(name="user_trial_router")
|
||||
|
||||
|
||||
async def request_trial_confirmation_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
subscription_service: SubscriptionService, session: AsyncSession):
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
user_id = callback.from_user.id
|
||||
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
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
@@ -29,50 +36,60 @@ async def request_trial_confirmation_handler(
|
||||
|
||||
show_trial_btn_in_menu_if_fail = False
|
||||
if settings.TRIAL_ENABLED:
|
||||
if not await subscription_service.has_had_any_subscription(
|
||||
session, user_id):
|
||||
if not await subscription_service.has_had_any_subscription(session, user_id):
|
||||
show_trial_btn_in_menu_if_fail = True
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
await callback.message.edit_text(
|
||||
_("trial_feature_disabled"),
|
||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
|
||||
settings, False))
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, False
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
await callback.message.edit_text(
|
||||
_("trial_already_had_subscription_or_trial"),
|
||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
|
||||
settings, False))
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, False
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
traffic_gb_display = str(
|
||||
settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
) if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0 else _(
|
||||
"traffic_unlimited")
|
||||
traffic_gb_display = (
|
||||
str(settings.TRIAL_TRAFFIC_LIMIT_GB)
|
||||
if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0
|
||||
else _("traffic_unlimited")
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=_("trial_confirm_prompt",
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_gb=traffic_gb_display),
|
||||
reply_markup=get_trial_confirmation_keyboard(current_lang, i18n))
|
||||
text=_(
|
||||
"trial_confirm_prompt",
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_gb=traffic_gb_display,
|
||||
),
|
||||
reply_markup=get_trial_confirmation_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "trial_action:confirm_activate")
|
||||
async def confirm_activate_trial_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService, session: AsyncSession):
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
user_id = callback.from_user.id
|
||||
|
||||
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
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
@@ -81,26 +98,22 @@ async def confirm_activate_trial_handler(
|
||||
if not settings.TRIAL_ENABLED:
|
||||
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
||||
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
return
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
await callback.answer(_("trial_already_had_subscription_or_trial"),
|
||||
show_alert=True)
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
await callback.answer(
|
||||
_("trial_already_had_subscription_or_trial"), show_alert=True
|
||||
)
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
return
|
||||
|
||||
activation_result = await subscription_service.activate_trial_subscription(
|
||||
session, user_id)
|
||||
session, user_id
|
||||
)
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
@@ -110,35 +123,43 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available")
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if config_link_for_trial == _(
|
||||
"config_link_not_available") and activation_result.get(
|
||||
"panel_short_uuid"):
|
||||
generated_link = await panel_service.get_subscription_link(
|
||||
activation_result["panel_short_uuid"])
|
||||
if generated_link: config_link_for_trial = generated_link
|
||||
|
||||
traffic_gb_val = activation_result.get("traffic_gb",
|
||||
settings.TRIAL_TRAFFIC_LIMIT_GB)
|
||||
traffic_display = f"{traffic_gb_val} GB" if traffic_gb_val and traffic_gb_val > 0 else _(
|
||||
"traffic_unlimited")
|
||||
traffic_gb_val = activation_result.get(
|
||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
)
|
||||
traffic_display = (
|
||||
f"{traffic_gb_val} GB"
|
||||
if traffic_gb_val and traffic_gb_val > 0
|
||||
else _("traffic_unlimited")
|
||||
)
|
||||
|
||||
final_message_text_in_chat = _(
|
||||
"trial_activated_details_message",
|
||||
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
|
||||
end_date=end_date_obj.strftime('%Y-%m-%d') if isinstance(
|
||||
end_date_obj, datetime) else "N/A",
|
||||
end_date=(
|
||||
end_date_obj.strftime("%Y-%m-%d")
|
||||
if isinstance(end_date_obj, datetime)
|
||||
else "N/A"
|
||||
),
|
||||
config_link=config_link_for_trial,
|
||||
traffic_gb=traffic_display)
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
else:
|
||||
message_key_from_service = activation_result.get(
|
||||
"message_key", "trial_activation_failed"
|
||||
) if activation_result else "trial_activation_failed"
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
if activation_result
|
||||
else "trial_activation_failed"
|
||||
)
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id):
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
try:
|
||||
@@ -146,31 +167,47 @@ async def confirm_activate_trial_handler(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action),
|
||||
disable_web_page_preview=True)
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Could not edit trial result message: {e_edit}. Sending new one.")
|
||||
f"Could not edit trial result message: {e_edit}. Sending new one."
|
||||
)
|
||||
|
||||
if callback.message and hasattr(callback.message,
|
||||
'chat') and callback.message.chat:
|
||||
if (
|
||||
callback.message
|
||||
and hasattr(callback.message, "chat")
|
||||
and callback.message.chat
|
||||
):
|
||||
await callback.message.chat.send_message(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings,
|
||||
show_trial_button_after_action),
|
||||
disable_web_page_preview=True)
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
if activation_result and activation_result.get("activated") and end_date_obj:
|
||||
await notify_admin_new_trial(
|
||||
callback.bot,
|
||||
settings,
|
||||
i18n,
|
||||
user_id,
|
||||
end_date_obj,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:cancel_trial")
|
||||
async def cancel_trial_activation(callback: types.CallbackQuery,
|
||||
settings: Settings, i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession):
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
async def cancel_trial_activation(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
|
||||
@@ -18,6 +18,8 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
||||
callback_data="admin_action:broadcast")
|
||||
builder.button(text=_(key="admin_create_promo_button"),
|
||||
callback_data="admin_action:create_promo")
|
||||
builder.button(text=_(key="admin_manage_promos_button"),
|
||||
callback_data="admin_action:manage_promos")
|
||||
builder.button(text=_(key="admin_view_promos_button"),
|
||||
callback_data="admin_action:view_promos")
|
||||
builder.button(text=_(key="admin_ban_user_button"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from aiogram.types import InlineKeyboardMarkup, WebAppInfo
|
||||
from typing import Dict, Optional, List
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -21,9 +21,20 @@ def get_main_menu_inline_keyboard(
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
|
||||
callback_data="main_action:subscribe"))
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_(key="menu_my_subscription_inline"),
|
||||
callback_data="main_action:my_subscription"))
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_my_subscription_inline"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_(key="menu_my_subscription_inline"),
|
||||
callback_data="main_action:my_subscription",
|
||||
)
|
||||
)
|
||||
|
||||
referral_button = InlineKeyboardButton(
|
||||
text=_(key="menu_referral_inline"),
|
||||
@@ -108,16 +119,24 @@ def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_confirm_subscription_keyboard(months: int, price: float,
|
||||
currency_symbol_val: str, lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
def get_payment_method_keyboard(months: int, price: float,
|
||||
tribute_url: Optional[str],
|
||||
stars_price: Optional[int],
|
||||
currency_symbol_val: str, lang: str,
|
||||
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
confirm_text = _(key="confirm_payment_button",
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val)
|
||||
builder.button(text=confirm_text,
|
||||
callback_data=f"confirm_sub:{months}:{price}")
|
||||
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.YOOKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{months}:{price}")
|
||||
if settings.CRYPTOPAY_ENABLED:
|
||||
builder.button(text=_("pay_with_cryptopay_button"),
|
||||
callback_data=f"pay_crypto:{months}:{price}")
|
||||
builder.button(text=_(key="cancel_button"),
|
||||
callback_data="main_action:subscribe")
|
||||
builder.adjust(1)
|
||||
@@ -153,11 +172,57 @@ def get_back_to_main_menu_markup(lang: str,
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_subscribe_only_markup(lang: str, i18n_instance) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="menu_subscribe_inline"),
|
||||
callback_data="main_action:subscribe")
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_user_banned_keyboard(support_link: Optional[str], lang: str,
|
||||
i18n_instance) -> Optional[InlineKeyboardMarkup]:
|
||||
if not support_link:
|
||||
return None
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="contact_support_button"), url=support_link)
|
||||
builder.button(text=_(key="menu_support_button"), url=support_link)
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_connect_and_main_keyboard(
|
||||
lang: str,
|
||||
i18n_instance,
|
||||
settings: Settings,
|
||||
config_link: Optional[str]) -> InlineKeyboardMarkup:
|
||||
"""Keyboard with a connect button and a back to main menu button."""
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("connect_button"),
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
elif config_link:
|
||||
builder.row(
|
||||
InlineKeyboardButton(text=_("connect_button"), url=config_link)
|
||||
)
|
||||
else:
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("connect_button"),
|
||||
callback_data="main_action:my_subscription",
|
||||
)
|
||||
)
|
||||
|
||||
builder.row(
|
||||
InlineKeyboardButton(
|
||||
text=_("back_to_main_menu_button"),
|
||||
callback_data="main_action:back_to_main",
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
+178
-95
@@ -3,14 +3,20 @@ import asyncio
|
||||
from typing import Callable, Dict, Any, Awaitable, Optional
|
||||
|
||||
from aiogram import Bot, Dispatcher, BaseMiddleware, Router, F
|
||||
from aiogram.types import Update
|
||||
from aiogram.types import (
|
||||
Update,
|
||||
MenuButtonDefault,
|
||||
MenuButtonWebApp,
|
||||
WebAppInfo,
|
||||
BotCommand,
|
||||
)
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.filters import CommandStart, Command
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
|
||||
from aiogram.fsm.storage.memory import MemoryStorage
|
||||
from aiohttp import web
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from bot.services.panel_webhook_service import PanelWebhookService, panel_webhook_route
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
@@ -25,12 +31,14 @@ from bot.handlers.user import user_router_aggregate
|
||||
from bot.handlers.admin import admin_router_aggregate
|
||||
from bot.filters.admin_filter import AdminFilter
|
||||
|
||||
from bot.services.notification_service import schedule_subscription_notifications
|
||||
from bot.services.payment_service import YooKassaService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService, tribute_webhook_route
|
||||
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
|
||||
|
||||
from bot.handlers.user import payment as user_payment_webhook_module
|
||||
|
||||
@@ -41,14 +49,17 @@ class DBSessionMiddleware(BaseMiddleware):
|
||||
super().__init__()
|
||||
self.async_session_factory = async_session_factory
|
||||
|
||||
async def __call__(self, handler: Callable[[Update, Dict[str, Any]],
|
||||
Awaitable[Any]], event: Update,
|
||||
data: Dict[str, Any]) -> Any:
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
if self.async_session_factory is None:
|
||||
logging.critical(
|
||||
"DBSessionMiddleware: async_session_factory is None!")
|
||||
logging.critical("DBSessionMiddleware: async_session_factory is None!")
|
||||
raise RuntimeError(
|
||||
"async_session_factory not provided to DBSessionMiddleware")
|
||||
"async_session_factory not provided to DBSessionMiddleware"
|
||||
)
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
data["session"] = session
|
||||
@@ -60,8 +71,8 @@ class DBSessionMiddleware(BaseMiddleware):
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
"DBSessionMiddleware: Exception caused rollback.",
|
||||
exc_info=True)
|
||||
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@@ -89,30 +100,13 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
async_session_factory: sessionmaker = dispatcher["async_session_factory"]
|
||||
|
||||
logging.info("STARTUP: on_startup_configured executing...")
|
||||
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
try:
|
||||
|
||||
await schedule_subscription_notifications(bot, settings, i18n_instance,
|
||||
scheduler, panel_service,
|
||||
async_session_factory)
|
||||
scheduler.start()
|
||||
dispatcher["scheduler"] = scheduler
|
||||
logging.info("STARTUP: APScheduler started.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to start APScheduler: {e}",
|
||||
exc_info=True)
|
||||
|
||||
telegram_webhook_url_to_set = getattr(settings,
|
||||
'TELEGRAM_WEBHOOK_BASE_URL', None)
|
||||
telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
|
||||
if telegram_webhook_url_to_set:
|
||||
if settings.BOT_TOKEN in telegram_webhook_url_to_set:
|
||||
logging.error(
|
||||
f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). "
|
||||
"This is a major security vulnerability. Webhook will NOT be set."
|
||||
)
|
||||
full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED"
|
||||
else:
|
||||
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
full_telegram_webhook_url = (
|
||||
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
|
||||
@@ -128,7 +122,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
set_success = await bot.set_webhook(
|
||||
url=full_telegram_webhook_url,
|
||||
drop_pending_updates=True,
|
||||
allowed_updates=dispatcher.resolve_used_update_types())
|
||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||
)
|
||||
if set_success:
|
||||
logging.info(
|
||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
|
||||
@@ -150,34 +145,57 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
except Exception as e_setwebhook:
|
||||
logging.error(
|
||||
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
||||
)
|
||||
else:
|
||||
logging.info(
|
||||
"STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set in environment. Attempting to delete any existing webhook (running in polling mode)."
|
||||
"STARTUP: WEBHOOK_BASE_URL not set in environment. Running in polling mode and clearing any existing webhook."
|
||||
)
|
||||
await bot.delete_webhook(drop_pending_updates=True)
|
||||
|
||||
if settings.SUBSCRIPTION_MINI_APP_URL:
|
||||
try:
|
||||
menu_text = i18n_instance.gettext(
|
||||
settings.DEFAULT_LANGUAGE,
|
||||
"menu_my_subscription_inline",
|
||||
)
|
||||
await bot.set_chat_menu_button(
|
||||
menu_button=MenuButtonWebApp(
|
||||
text=menu_text,
|
||||
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
|
||||
)
|
||||
)
|
||||
await bot.set_chat_menu_button(menu_button=MenuButtonDefault())
|
||||
logging.info(
|
||||
"STARTUP: Mini app domain registered and default menu button restored."
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"STARTUP: Failed to register mini app domain: {e}", exc_info=True
|
||||
)
|
||||
|
||||
if settings.START_COMMAND_DESCRIPTION:
|
||||
try:
|
||||
await bot.set_my_commands([
|
||||
BotCommand(command="start", description=settings.START_COMMAND_DESCRIPTION)
|
||||
])
|
||||
logging.info("STARTUP: /start command description set.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to set bot commands: {e}", exc_info=True)
|
||||
|
||||
logging.info("STARTUP: Bot on_startup_configured completed.")
|
||||
|
||||
|
||||
async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
logging.warning("SHUTDOWN: on_shutdown_configured executing...")
|
||||
|
||||
scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
|
||||
if scheduler and scheduler.running:
|
||||
try:
|
||||
scheduler.shutdown(wait=False)
|
||||
logging.info("SHUTDOWN: APScheduler shut down.")
|
||||
except Exception as e:
|
||||
logging.error(f"SHUTDOWN: Error shutting down APScheduler: {e}",
|
||||
exc_info=True)
|
||||
|
||||
panel_service: Optional[PanelApiService] = dispatcher.get("panel_service")
|
||||
if panel_service and hasattr(panel_service, 'close_session'):
|
||||
if panel_service and hasattr(panel_service, "close_session"):
|
||||
await panel_service.close_session()
|
||||
logging.info("Panel API service session closed on shutdown.")
|
||||
|
||||
@@ -190,6 +208,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
logging.warning(f"SHUTDOWN: Failed to close bot session: {e}")
|
||||
|
||||
from db.database_setup import async_engine as global_async_engine
|
||||
|
||||
if global_async_engine:
|
||||
logging.info("SHUTDOWN: Disposing SQLAlchemy engine...")
|
||||
await global_async_engine.dispose()
|
||||
@@ -222,22 +241,56 @@ async def run_bot(settings_param: Settings):
|
||||
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
|
||||
)
|
||||
|
||||
i18n_instance = get_i18n_instance(path="locales",
|
||||
default=settings_param.DEFAULT_LANGUAGE)
|
||||
i18n_instance = get_i18n_instance(
|
||||
path="locales", default=settings_param.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
yookassa_service = YooKassaService(
|
||||
shop_id=settings_param.YOOKASSA_SHOP_ID,
|
||||
secret_key=settings_param.YOOKASSA_SECRET_KEY,
|
||||
configured_return_url=settings_param.YOOKASSA_RETURN_URL,
|
||||
bot_username_for_default_return=actual_bot_username,
|
||||
settings_obj=settings_param)
|
||||
settings_obj=settings_param,
|
||||
)
|
||||
panel_service = PanelApiService(settings_param)
|
||||
|
||||
subscription_service = SubscriptionService(settings_param, panel_service)
|
||||
referral_service = ReferralService(settings_param, subscription_service,
|
||||
bot, i18n_instance)
|
||||
promo_code_service = PromoCodeService(settings_param, subscription_service,
|
||||
bot, i18n_instance)
|
||||
subscription_service = SubscriptionService(
|
||||
settings_param, panel_service, bot, i18n_instance
|
||||
)
|
||||
referral_service = ReferralService(
|
||||
settings_param, subscription_service, bot, i18n_instance
|
||||
)
|
||||
promo_code_service = PromoCodeService(
|
||||
settings_param, subscription_service, bot, i18n_instance
|
||||
)
|
||||
stars_service = StarsService(
|
||||
bot, settings_param, i18n_instance, subscription_service, referral_service
|
||||
)
|
||||
cryptopay_service = CryptoPayService(
|
||||
settings_param.CRYPTOPAY_TOKEN,
|
||||
settings_param.CRYPTOPAY_NETWORK,
|
||||
bot,
|
||||
settings_param,
|
||||
i18n_instance,
|
||||
local_async_session_factory,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
tribute_service = TributeService(
|
||||
bot,
|
||||
settings_param,
|
||||
i18n_instance,
|
||||
local_async_session_factory,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
panel_webhook_service = PanelWebhookService(
|
||||
bot,
|
||||
settings_param,
|
||||
i18n_instance,
|
||||
local_async_session_factory,
|
||||
)
|
||||
|
||||
dp["i18n_instance"] = i18n_instance
|
||||
dp["yookassa_service"] = yookassa_service
|
||||
@@ -245,15 +298,19 @@ async def run_bot(settings_param: Settings):
|
||||
dp["subscription_service"] = subscription_service
|
||||
dp["referral_service"] = referral_service
|
||||
dp["promo_code_service"] = promo_code_service
|
||||
dp["stars_service"] = stars_service
|
||||
dp["cryptopay_service"] = cryptopay_service
|
||||
dp["tribute_service"] = tribute_service
|
||||
dp["panel_webhook_service"] = panel_webhook_service
|
||||
dp["async_session_factory"] = local_async_session_factory
|
||||
|
||||
dp.update.outer_middleware(DBSessionMiddleware(local_async_session_factory))
|
||||
dp.update.outer_middleware(
|
||||
DBSessionMiddleware(local_async_session_factory))
|
||||
I18nMiddleware(i18n=i18n_instance, settings=settings_param)
|
||||
)
|
||||
dp.update.outer_middleware(
|
||||
I18nMiddleware(i18n=i18n_instance, settings=settings_param))
|
||||
dp.update.outer_middleware(
|
||||
BanCheckMiddleware(settings=settings_param,
|
||||
i18n_instance=i18n_instance))
|
||||
BanCheckMiddleware(settings=settings_param, i18n_instance=i18n_instance)
|
||||
)
|
||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param))
|
||||
|
||||
dp.startup.register(on_startup_configured)
|
||||
@@ -261,23 +318,22 @@ async def run_bot(settings_param: Settings):
|
||||
|
||||
await register_all_routers(dp, settings_param)
|
||||
|
||||
tg_webhook_base = getattr(settings_param, 'TELEGRAM_WEBHOOK_BASE_URL',
|
||||
None)
|
||||
yk_webhook_base = getattr(settings_param, 'YOOKASSA_WEBHOOK_BASE_URL',
|
||||
None)
|
||||
tg_webhook_base = settings_param.WEBHOOK_BASE_URL
|
||||
yk_webhook_base = settings_param.WEBHOOK_BASE_URL
|
||||
|
||||
should_run_aiohttp_server = bool(tg_webhook_base) or (
|
||||
bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path))
|
||||
bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path)
|
||||
)
|
||||
|
||||
telegram_uses_webhook_mode = bool(tg_webhook_base)
|
||||
run_telegram_polling = not telegram_uses_webhook_mode
|
||||
|
||||
logging.info(f"--- Bot Run Mode Decision ---")
|
||||
logging.info(
|
||||
f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}"
|
||||
f"Configured WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}"
|
||||
)
|
||||
logging.info(
|
||||
f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}' & Path: '{settings_param.yookassa_webhook_path}'"
|
||||
f"YooKassa webhook path: '{settings_param.yookassa_webhook_path}'"
|
||||
)
|
||||
logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp_server}")
|
||||
logging.info(f"Decision: Run Telegram Polling: {run_telegram_polling}")
|
||||
@@ -288,25 +344,30 @@ async def run_bot(settings_param: Settings):
|
||||
|
||||
if should_run_aiohttp_server:
|
||||
app = web.Application()
|
||||
app['bot'] = bot
|
||||
app['dp'] = dp
|
||||
app['settings'] = settings_param
|
||||
app['i18n'] = i18n_instance
|
||||
app['async_session_factory'] = local_async_session_factory
|
||||
app["bot"] = bot
|
||||
app["dp"] = dp
|
||||
app["settings"] = settings_param
|
||||
app["i18n"] = i18n_instance
|
||||
app["async_session_factory"] = local_async_session_factory
|
||||
|
||||
app['yookassa_service'] = yookassa_service
|
||||
app['subscription_service'] = subscription_service
|
||||
app['referral_service'] = referral_service
|
||||
app['panel_service'] = panel_service
|
||||
app["yookassa_service"] = yookassa_service
|
||||
app["subscription_service"] = subscription_service
|
||||
app["referral_service"] = referral_service
|
||||
app["panel_service"] = panel_service
|
||||
app["stars_service"] = stars_service
|
||||
app["cryptopay_service"] = cryptopay_service
|
||||
app["tribute_service"] = tribute_service
|
||||
app["panel_webhook_service"] = panel_webhook_service
|
||||
|
||||
setup_application(app, dp, bot=bot)
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = f"/{settings_param.BOT_TOKEN}"
|
||||
if not telegram_webhook_path.startswith('/'):
|
||||
telegram_webhook_path = '/' + telegram_webhook_path
|
||||
app.router.add_post(telegram_webhook_path,
|
||||
SimpleRequestHandler(dispatcher=dp, bot=bot))
|
||||
if not telegram_webhook_path.startswith("/"):
|
||||
telegram_webhook_path = "/" + telegram_webhook_path
|
||||
app.router.add_post(
|
||||
telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot)
|
||||
)
|
||||
logging.info(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||
)
|
||||
@@ -317,40 +378,62 @@ async def run_bot(settings_param: Settings):
|
||||
logging.error(
|
||||
f"YooKassa webhook path is invalid or not configured in settings: {yk_path}. Skipping YooKassa webhook setup."
|
||||
)
|
||||
elif not yk_path.startswith('/'):
|
||||
elif not yk_path.startswith("/"):
|
||||
logging.error(
|
||||
f"CRITICAL: YooKassa webhook path '{yk_path}' from settings does not start with '/'. Correct settings.py or .env. Skipping YooKassa webhook."
|
||||
)
|
||||
else:
|
||||
app.router.add_post(
|
||||
yk_path,
|
||||
user_payment_webhook_module.yookassa_webhook_route)
|
||||
logging.info(
|
||||
f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
yk_path, user_payment_webhook_module.yookassa_webhook_route
|
||||
)
|
||||
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
|
||||
tribute_path = settings_param.tribute_webhook_path
|
||||
if tribute_path.startswith("/"):
|
||||
app.router.add_post(tribute_path, tribute_webhook_route)
|
||||
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
|
||||
|
||||
cp_path = settings_param.cryptopay_webhook_path
|
||||
if cp_path.startswith("/"):
|
||||
app.router.add_post(cp_path, cryptopay_webhook_route)
|
||||
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
|
||||
|
||||
panel_path = settings_param.panel_webhook_path
|
||||
if panel_path.startswith("/"):
|
||||
app.router.add_post(panel_path, panel_webhook_route)
|
||||
logging.info(f"Panel webhook route configured at: [POST] {panel_path}")
|
||||
|
||||
web_app_runner = web.AppRunner(app)
|
||||
await web_app_runner.setup()
|
||||
site = web.TCPSite(web_app_runner,
|
||||
host=settings_param.WEB_SERVER_HOST,
|
||||
port=settings_param.WEB_SERVER_PORT)
|
||||
site = web.TCPSite(
|
||||
web_app_runner,
|
||||
host=settings_param.WEB_SERVER_HOST,
|
||||
port=settings_param.WEB_SERVER_PORT,
|
||||
)
|
||||
|
||||
async def web_server_task():
|
||||
await site.start()
|
||||
logging.info(
|
||||
f"AIOHTTP server started on http://{settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}"
|
||||
)
|
||||
await asyncio.Event().wait(
|
||||
) if not run_telegram_polling else await asyncio.sleep(31536000)
|
||||
(
|
||||
await asyncio.Event().wait()
|
||||
if not run_telegram_polling
|
||||
else await asyncio.sleep(31536000)
|
||||
)
|
||||
|
||||
main_tasks.append(
|
||||
asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
|
||||
asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")
|
||||
)
|
||||
|
||||
if run_telegram_polling:
|
||||
logging.info("Starting bot in Telegram Polling mode...")
|
||||
main_tasks.append(
|
||||
asyncio.create_task(dp.start_polling(
|
||||
bot, allowed_updates=dp.resolve_used_update_types()),
|
||||
name="TelegramPollingTask"))
|
||||
asyncio.create_task(
|
||||
dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()),
|
||||
name="TelegramPollingTask",
|
||||
)
|
||||
)
|
||||
|
||||
if not main_tasks:
|
||||
logging.error(
|
||||
@@ -366,8 +449,7 @@ async def run_bot(settings_param: Settings):
|
||||
try:
|
||||
await asyncio.gather(*main_tasks)
|
||||
except (KeyboardInterrupt, SystemExit, asyncio.CancelledError) as e:
|
||||
logging.info(
|
||||
f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
|
||||
logging.info(f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
|
||||
finally:
|
||||
logging.info("Initiating final bot shutdown sequence...")
|
||||
for task in main_tasks:
|
||||
@@ -382,7 +464,8 @@ async def run_bot(settings_param: Settings):
|
||||
except Exception as e_task_cancel:
|
||||
logging.error(
|
||||
f"Error during cancellation of task '{task.get_name()}': {e_task_cancel}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if web_app_runner:
|
||||
await web_app_runner.cleanup()
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot
|
||||
from aiohttp import web
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from aiocryptopay import AioCryptoPay, Networks
|
||||
from aiocryptopay.models.update import Update
|
||||
|
||||
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 notify_admin_new_payment
|
||||
from db.dal import payment_dal, user_dal
|
||||
|
||||
|
||||
class CryptoPayService:
|
||||
def __init__(
|
||||
self,
|
||||
token: Optional[str],
|
||||
network: str,
|
||||
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
|
||||
if token:
|
||||
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
|
||||
self.client = AioCryptoPay(token=token, network=net)
|
||||
self.client.register_pay_handler(self._invoice_paid_handler)
|
||||
self.configured = True
|
||||
else:
|
||||
logging.warning("CryptoPay token not provided. CryptoPay disabled")
|
||||
self.client = None
|
||||
self.configured = False
|
||||
|
||||
async def create_invoice(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
months: int,
|
||||
amount: float,
|
||||
description: str,
|
||||
) -> Optional[str]:
|
||||
if not self.configured or not self.client:
|
||||
logging.error("CryptoPayService not configured")
|
||||
return None
|
||||
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
"user_id": user_id,
|
||||
"amount": float(amount),
|
||||
"currency": self.settings.CRYPTOPAY_ASSET,
|
||||
"status": "pending_cryptopay",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider": "cryptopay",
|
||||
},
|
||||
)
|
||||
payload = json.dumps({
|
||||
"user_id": str(user_id),
|
||||
"subscription_months": str(months),
|
||||
"payment_db_id": str(payment_record.payment_id),
|
||||
})
|
||||
try:
|
||||
invoice = await self.client.create_invoice(
|
||||
amount=amount,
|
||||
currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE,
|
||||
fiat=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat" else None,
|
||||
asset=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto" else None,
|
||||
description=description,
|
||||
payload=payload,
|
||||
)
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_record.payment_id,
|
||||
str(invoice.invoice_id),
|
||||
str(invoice.status),
|
||||
)
|
||||
return invoice.bot_invoice_url
|
||||
except Exception as e:
|
||||
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
async def _invoice_paid_handler(self, update: Update, app: web.Application):
|
||||
invoice = update.payload
|
||||
if not invoice.payload:
|
||||
logging.warning("CryptoPay webhook without payload")
|
||||
return
|
||||
try:
|
||||
meta = json.loads(invoice.payload)
|
||||
user_id = int(meta["user_id"])
|
||||
months = int(meta["subscription_months"])
|
||||
payment_db_id = int(meta["payment_db_id"])
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to parse CryptoPay payload: {e}")
|
||||
return
|
||||
|
||||
async_session_factory: sessionmaker = app["async_session_factory"]
|
||||
bot: Bot = app["bot"]
|
||||
settings: Settings = app["settings"]
|
||||
i18n: JsonI18n = app["i18n"]
|
||||
subscription_service: SubscriptionService = app["subscription_service"]
|
||||
referral_service: ReferralService = app["referral_service"]
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session,
|
||||
payment_db_id,
|
||||
str(invoice.invoice_id),
|
||||
"succeeded",
|
||||
)
|
||||
activation = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
float(invoice.amount),
|
||||
payment_db_id,
|
||||
provider="cryptopay",
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session, user_id, months
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to process CryptoPay invoice: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
config_link = activation.get("subscription_url") or _("config_link_not_available")
|
||||
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 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 and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
text = _("payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link)
|
||||
else:
|
||||
text = _("payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link)
|
||||
|
||||
markup = get_connect_and_main_keyboard(lang, i18n, settings, config_link)
|
||||
try:
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
text,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send CryptoPay success message: {e}")
|
||||
|
||||
await notify_admin_new_payment(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
user_id,
|
||||
months,
|
||||
float(invoice.amount),
|
||||
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
|
||||
)
|
||||
|
||||
async def webhook_route(self, request: web.Request) -> web.Response:
|
||||
if not self.configured or not self.client:
|
||||
return web.Response(status=503, text="cryptopay_disabled")
|
||||
return await self.client.get_updates(request)
|
||||
|
||||
|
||||
async def cryptopay_webhook_route(request: web.Request) -> web.Response:
|
||||
service: CryptoPayService = request.app["cryptopay_service"]
|
||||
return await service.webhook_route(request)
|
||||
@@ -1,135 +1,68 @@
|
||||
import logging
|
||||
import asyncio
|
||||
from aiogram import Bot
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from aiogram.utils.text_decorations import html_decoration as hd
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
|
||||
|
||||
async def send_expiration_warnings(bot: Bot, settings: Settings,
|
||||
i18n: JsonI18n,
|
||||
panel_service: PanelApiService,
|
||||
async_session_factory: sessionmaker):
|
||||
|
||||
logging.info(
|
||||
f"Scheduler job 'send_expiration_warnings' started at {datetime.now(timezone.utc)} UTC."
|
||||
)
|
||||
|
||||
if async_session_factory is None:
|
||||
logging.error(
|
||||
"NotificationService: AsyncSessionFactory not provided to send_expiration_warnings!"
|
||||
)
|
||||
async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
message_key: str, parse_mode: str | None = None,
|
||||
**kwargs) -> None:
|
||||
if not settings.ADMIN_IDS:
|
||||
return
|
||||
|
||||
async with async_session_factory() as session:
|
||||
admin_lang = settings.DEFAULT_LANGUAGE
|
||||
msg = i18n.gettext(admin_lang, message_key, **kwargs)
|
||||
for admin_id in settings.ADMIN_IDS:
|
||||
try:
|
||||
|
||||
sub_service = SubscriptionService(settings, panel_service)
|
||||
|
||||
expiring_subs_details_list = await sub_service.get_subscriptions_ending_soon(
|
||||
session, settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
|
||||
|
||||
if not expiring_subs_details_list:
|
||||
logging.info(
|
||||
"No subscriptions found ending soon for notification.")
|
||||
return
|
||||
|
||||
logging.info(
|
||||
f"Found {len(expiring_subs_details_list)} subscriptions for expiration warning."
|
||||
)
|
||||
|
||||
for sub_details in expiring_subs_details_list:
|
||||
user_id = sub_details['user_id']
|
||||
user_lang = sub_details.get('language_code',
|
||||
settings.DEFAULT_LANGUAGE)
|
||||
first_name = sub_details.get('first_name', f"User {user_id}")
|
||||
end_date_str_for_msg = sub_details.get('end_date_str', "N/A")
|
||||
days_left_display = sub_details.get('days_left', "N/A")
|
||||
|
||||
subscription_actual_end_date_obj: Optional[
|
||||
datetime] = sub_details.get(
|
||||
'subscription_end_date_iso_for_update')
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
|
||||
)
|
||||
message_text = _("subscription_ending_soon_notification",
|
||||
user_name=first_name,
|
||||
end_date=end_date_str_for_msg,
|
||||
days_left=days_left_display)
|
||||
try:
|
||||
await bot.send_message(user_id, message_text)
|
||||
logging.info(
|
||||
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str_for_msg}."
|
||||
)
|
||||
|
||||
if subscription_actual_end_date_obj:
|
||||
await sub_service.update_last_notification_sent(
|
||||
session, user_id, subscription_actual_end_date_obj)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Could not find exact subscription end_date_obj for user {user_id} to update notification time."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send expiration warning or update notification status for user {user_id}: {e}",
|
||||
exc_info=True)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await session.commit()
|
||||
logging.info(
|
||||
"Finished processing expiration warnings. Session committed.")
|
||||
|
||||
except Exception as e_session:
|
||||
logging.error(
|
||||
f"Error during send_expiration_warnings session: {e_session}",
|
||||
exc_info=True)
|
||||
await session.rollback()
|
||||
logging.info(
|
||||
"Session rolled back due to error in send_expiration_warnings."
|
||||
)
|
||||
|
||||
|
||||
async def schedule_subscription_notifications(
|
||||
bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
scheduler: AsyncIOScheduler, panel_service: PanelApiService,
|
||||
async_session_factory: sessionmaker):
|
||||
|
||||
async def job_wrapper():
|
||||
|
||||
try:
|
||||
await send_expiration_warnings(bot, settings, i18n, panel_service,
|
||||
async_session_factory)
|
||||
await bot.send_message(admin_id, msg, parse_mode=parse_mode)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Unhandled error in scheduled job 'send_expiration_warnings' (job_wrapper): {e}",
|
||||
exc_info=True)
|
||||
logging.error(f"Failed to send admin notification to {admin_id}: {e}")
|
||||
|
||||
try:
|
||||
notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC)
|
||||
notification_minute = int(
|
||||
settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC)
|
||||
except (ValueError, TypeError):
|
||||
logging.warning(
|
||||
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid in settings. Defaulting to 9:00 UTC."
|
||||
)
|
||||
notification_hour = 9
|
||||
notification_minute = 0
|
||||
|
||||
scheduler.add_job(job_wrapper,
|
||||
'cron',
|
||||
hour=notification_hour,
|
||||
minute=notification_minute,
|
||||
name="daily_subscription_expiration_warnings_v2",
|
||||
misfire_grace_time=60 * 15,
|
||||
replace_existing=True)
|
||||
logging.info(
|
||||
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
|
||||
async def notify_admin_new_trial(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
user_id: int, end_date: datetime) -> None:
|
||||
end_date_str = end_date.strftime('%Y-%m-%d') if isinstance(end_date, datetime) else str(end_date)
|
||||
await notify_admins(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
"admin_new_trial_notification",
|
||||
user_id=user_id,
|
||||
end_date=end_date_str,
|
||||
)
|
||||
|
||||
|
||||
async def notify_admin_new_payment(bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
user_id: int, months: int, amount: float,
|
||||
currency: str | None = None) -> None:
|
||||
currency_symbol = currency or settings.DEFAULT_CURRENCY_SYMBOL
|
||||
await notify_admins(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
"admin_new_payment_notification",
|
||||
user_id=user_id,
|
||||
months=months,
|
||||
amount=f"{amount:.2f}",
|
||||
currency=currency_symbol,
|
||||
)
|
||||
|
||||
|
||||
async def notify_admin_promo_activation(bot: Bot, settings: Settings,
|
||||
i18n: JsonI18n, user_id: int,
|
||||
code: str,
|
||||
bonus_days: int) -> None:
|
||||
await notify_admins(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
"admin_promo_activation_notification",
|
||||
user_id=user_id,
|
||||
code=code,
|
||||
bonus_days=bonus_days,
|
||||
)
|
||||
|
||||
@@ -226,6 +226,28 @@ class PanelApiService:
|
||||
|
||||
return None
|
||||
|
||||
async def get_user(
|
||||
self,
|
||||
*,
|
||||
uuid: Optional[str] = None,
|
||||
telegram_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
log_response: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if uuid:
|
||||
return await self.get_user_by_uuid(uuid, log_response=log_response)
|
||||
|
||||
users = await self.get_users_by_filter(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
email=email,
|
||||
log_response=log_response,
|
||||
)
|
||||
if users:
|
||||
return users[0]
|
||||
return None
|
||||
|
||||
async def get_users_by_filter(
|
||||
self,
|
||||
telegram_id: Optional[int] = None,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import json
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardMarkup
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from typing import Optional
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
|
||||
from db.dal import user_dal
|
||||
|
||||
EVENT_MAP = {
|
||||
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
|
||||
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
|
||||
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
|
||||
}
|
||||
|
||||
class PanelWebhookService:
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n, async_session_factory: sessionmaker):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
|
||||
async def _send_message(
|
||||
self,
|
||||
user_id: int,
|
||||
lang: str,
|
||||
message_key: str,
|
||||
reply_markup: InlineKeyboardMarkup | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
user_id, _(message_key, **kwargs), reply_markup=reply_markup
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to send notification to {user_id}: {e}")
|
||||
|
||||
async def handle_event(self, event_name: str, user_payload: dict):
|
||||
telegram_id = user_payload.get("telegramId")
|
||||
if not telegram_id:
|
||||
logging.warning("Panel webhook without telegramId received")
|
||||
return
|
||||
user_id = int(telegram_id)
|
||||
|
||||
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
|
||||
return
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
|
||||
|
||||
markup = get_subscribe_only_markup(lang, self.i18n)
|
||||
|
||||
if event_name in EVENT_MAP:
|
||||
days_left, msg_key = EVENT_MAP[event_name]
|
||||
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
msg_key,
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
elif event_name == "user.expired_24_hours_ago" and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE:
|
||||
await self._send_message(
|
||||
user_id,
|
||||
lang,
|
||||
"subscription_expired_yesterday_notification",
|
||||
reply_markup=markup,
|
||||
user_name=first_name,
|
||||
end_date=user_payload.get("expireAt", "")[:10],
|
||||
)
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
|
||||
if self.settings.PANEL_WEBHOOK_SECRET:
|
||||
if not signature_header:
|
||||
return web.Response(status=403, text="no_signature")
|
||||
expected_sig = hmac.new(
|
||||
self.settings.PANEL_WEBHOOK_SECRET.encode(),
|
||||
raw_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
except Exception:
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
event_name = payload.get("name") or payload.get("event")
|
||||
user_data = payload.get("payload") or payload.get("data", {})
|
||||
if isinstance(user_data, dict) and "user" in user_data:
|
||||
user_data = user_data.get("user") or user_data
|
||||
|
||||
telegram_id = user_data.get("telegramId") if isinstance(user_data, dict) else None
|
||||
|
||||
if not event_name:
|
||||
return web.Response(status=200, text="ok_no_event")
|
||||
|
||||
logging.info(
|
||||
"Panel webhook event received: %s; telegramId=%s",
|
||||
event_name,
|
||||
telegram_id if telegram_id is not None else "N/A",
|
||||
)
|
||||
|
||||
await self.handle_event(event_name, user_data)
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
async def panel_webhook_route(request: web.Request):
|
||||
service: PanelWebhookService = request.app["panel_webhook_service"]
|
||||
raw = await request.read()
|
||||
signature_header = request.headers.get("X-Remnawave-Signature")
|
||||
return await service.handle_webhook(raw, signature_header)
|
||||
@@ -1,15 +1,17 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Optional, Tuple, Dict
|
||||
from aiogram import Bot
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from db.dal import promo_code_dal, user_dal, subscription_dal
|
||||
from db.dal import promo_code_dal, user_dal
|
||||
from db.models import PromoCode, User
|
||||
|
||||
from .subscription_service import SubscriptionService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import notify_admin_promo_activation
|
||||
|
||||
|
||||
class PromoCodeService:
|
||||
@@ -22,9 +24,13 @@ class PromoCodeService:
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
async def apply_promo_code(self, session: AsyncSession, user_id: int,
|
||||
code_input: str,
|
||||
user_lang: str) -> Tuple[bool, str]:
|
||||
async def apply_promo_code(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
code_input: str,
|
||||
user_lang: str,
|
||||
) -> Tuple[bool, datetime | str]:
|
||||
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
|
||||
code_input_upper = code_input.strip().upper()
|
||||
|
||||
@@ -40,11 +46,6 @@ class PromoCodeService:
|
||||
return False, _("promo_code_already_used_by_user",
|
||||
code=code_input_upper)
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id)
|
||||
if not active_sub:
|
||||
return False, _("promo_code_no_active_subscription")
|
||||
|
||||
bonus_days = promo_data.bonus_days
|
||||
|
||||
new_end_date = await self.subscription_service.extend_active_subscription_days(
|
||||
@@ -61,11 +62,15 @@ class PromoCodeService:
|
||||
session, promo_data.promo_code_id)
|
||||
|
||||
if activation_recorded and promo_incremented:
|
||||
|
||||
return True, _("promo_code_applied_success",
|
||||
code=code_input_upper,
|
||||
bonus_days=bonus_days,
|
||||
new_end_date=new_end_date.strftime('%Y-%m-%d'))
|
||||
await notify_admin_promo_activation(
|
||||
self.bot,
|
||||
self.settings,
|
||||
self.i18n,
|
||||
user_id,
|
||||
code_input_upper,
|
||||
bonus_days,
|
||||
)
|
||||
return True, new_end_date
|
||||
else:
|
||||
|
||||
logging.error(
|
||||
|
||||
@@ -137,8 +137,7 @@ class ReferralService:
|
||||
"status_from_panel":
|
||||
"ACTIVE_BONUS",
|
||||
"traffic_limit_bytes":
|
||||
self.settings.
|
||||
PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
self.settings.user_traffic_limit_bytes,
|
||||
}
|
||||
try:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot, types
|
||||
from aiogram.types import LabeledPrice
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_dal
|
||||
from .subscription_service import SubscriptionService
|
||||
from .referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .notification_service import notify_admin_new_payment
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
|
||||
|
||||
class StarsService:
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
|
||||
stars_price: int, description: str) -> Optional[int]:
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": float(stars_price),
|
||||
"currency": "XTR",
|
||||
"status": "pending_stars",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider": "telegram_stars",
|
||||
}
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(
|
||||
session, payment_record_data)
|
||||
await session.commit()
|
||||
except Exception as e_db:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to create stars payment record: {e_db}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
payload = f"{db_payment_record.payment_id}:{months}"
|
||||
prices = [LabeledPrice(label=description, amount=stars_price)]
|
||||
try:
|
||||
await self.bot.send_invoice(
|
||||
chat_id=user_id,
|
||||
title=description,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=prices,
|
||||
)
|
||||
return db_payment_record.payment_id
|
||||
except Exception as e_inv:
|
||||
logging.error(f"Failed to send Telegram Stars invoice: {e_inv}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
async def process_successful_payment(self, session: AsyncSession,
|
||||
message: types.Message,
|
||||
payment_db_id: int,
|
||||
months: int,
|
||||
stars_amount: int,
|
||||
i18n_data: dict) -> None:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session, payment_db_id,
|
||||
message.successful_payment.provider_payment_charge_id,
|
||||
"succeeded")
|
||||
await session.commit()
|
||||
except Exception as e_upd:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update stars payment record {payment_db_id}: {e_upd}",
|
||||
exc_info=True)
|
||||
return
|
||||
|
||||
activation_details = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
message.from_user.id,
|
||||
months,
|
||||
float(stars_amount),
|
||||
payment_db_id,
|
||||
provider="telegram_stars",
|
||||
)
|
||||
if not activation_details or not activation_details.get("end_date"):
|
||||
logging.error(
|
||||
f"Failed to activate subscription after stars payment for user {message.from_user.id}")
|
||||
return
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session, message.from_user.id, months)
|
||||
await session.commit()
|
||||
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None
|
||||
final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None
|
||||
if not final_end:
|
||||
final_end = activation_details["end_date"]
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
self.settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
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}"
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
current_lang, i18n, self.settings, config_link
|
||||
)
|
||||
try:
|
||||
await self.bot.send_message(
|
||||
message.from_user.id,
|
||||
success_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_send:
|
||||
logging.error(
|
||||
f"Failed to send stars payment success message: {e_send}")
|
||||
|
||||
await notify_admin_new_payment(
|
||||
self.bot,
|
||||
self.settings,
|
||||
self.i18n,
|
||||
message.from_user.id,
|
||||
months,
|
||||
float(stars_amount),
|
||||
currency="XTR",
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@ import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from aiogram import Bot
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal
|
||||
from db.models import User, Subscription
|
||||
@@ -12,26 +14,48 @@ from .panel_api_service import PanelApiService
|
||||
|
||||
class SubscriptionService:
|
||||
|
||||
def __init__(self, settings: Settings, panel_service: PanelApiService):
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
bot: Optional[Bot] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
):
|
||||
self.settings = settings
|
||||
self.panel_service = panel_service
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
async def get_user_language(self, session: AsyncSession,
|
||||
user_id: int) -> str:
|
||||
async def get_user_language(self, session: AsyncSession, user_id: int) -> str:
|
||||
user_record = await user_dal.get_user_by_id(session, user_id)
|
||||
return user_record.language_code if user_record and user_record.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
return (
|
||||
user_record.language_code
|
||||
if user_record and user_record.language_code
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
async def has_had_any_subscription(self, session: AsyncSession,
|
||||
user_id: int) -> bool:
|
||||
async def has_had_any_subscription(
|
||||
self, session: AsyncSession, user_id: int
|
||||
) -> bool:
|
||||
|
||||
return await subscription_dal.has_any_subscription_for_user(
|
||||
session, user_id)
|
||||
return await subscription_dal.has_any_subscription_for_user(session, user_id)
|
||||
|
||||
async def _notify_admin_panel_user_creation_failed(self, user_id: int):
|
||||
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
|
||||
return
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_adm = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw)
|
||||
msg = _adm("admin_panel_user_creation_failed", user_id=user_id)
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
try:
|
||||
await self.bot.send_message(admin_id, msg)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
|
||||
)
|
||||
|
||||
async def _get_or_create_panel_user_link_details(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
db_user: Optional[User] = None
|
||||
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -49,7 +73,8 @@ class SubscriptionService:
|
||||
panel_user_created_or_linked_now = False
|
||||
|
||||
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
|
||||
telegram_id=user_id)
|
||||
telegram_id=user_id
|
||||
)
|
||||
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
|
||||
panel_user_obj_from_api = panel_users_by_tg_id_list[0]
|
||||
logging.info(
|
||||
@@ -68,11 +93,32 @@ class SubscriptionService:
|
||||
f"User {user_id} (local panel_uuid: {current_local_panel_uuid}) not found on panel by TG ID. Fetching by panel_uuid."
|
||||
)
|
||||
panel_user_obj_from_api = await self.panel_service.get_user_by_uuid(
|
||||
current_local_panel_uuid)
|
||||
current_local_panel_uuid
|
||||
)
|
||||
if not panel_user_obj_from_api:
|
||||
logging.warning(
|
||||
f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced."
|
||||
)
|
||||
logging.info(
|
||||
f"Creating new panel user '{panel_username_on_panel_standard}' for TG user {user_id}."
|
||||
)
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username_on_panel=panel_username_on_panel_standard,
|
||||
telegram_id=user_id,
|
||||
specific_inbound_uuids=self.settings.parsed_user_inbound_uuids,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
if (
|
||||
creation_response
|
||||
and not creation_response.get("error")
|
||||
and creation_response.get("response")
|
||||
):
|
||||
panel_user_obj_from_api = creation_response.get("response")
|
||||
panel_user_created_or_linked_now = True
|
||||
else:
|
||||
await self._notify_admin_panel_user_creation_failed(user_id)
|
||||
return None, None, None, False
|
||||
|
||||
else:
|
||||
|
||||
@@ -82,27 +128,35 @@ class SubscriptionService:
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username_on_panel=panel_username_on_panel_standard,
|
||||
telegram_id=user_id,
|
||||
specific_inbound_uuids=self.settings.parsed_user_inbound_uuids,
|
||||
default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes,
|
||||
default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY,
|
||||
)
|
||||
if creation_response and not creation_response.get(
|
||||
"error") and creation_response.get("response"):
|
||||
if (
|
||||
creation_response
|
||||
and not creation_response.get("error")
|
||||
and creation_response.get("response")
|
||||
):
|
||||
panel_user_obj_from_api = creation_response.get("response")
|
||||
panel_user_created_or_linked_now = True
|
||||
|
||||
elif creation_response and creation_response.get(
|
||||
"errorCode") == "A019":
|
||||
elif creation_response and creation_response.get("errorCode") == "A019":
|
||||
logging.warning(
|
||||
f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username."
|
||||
)
|
||||
fetched_by_username_list = await self.panel_service.get_users_by_filter(
|
||||
username=panel_username_on_panel_standard)
|
||||
if fetched_by_username_list and len(
|
||||
fetched_by_username_list) == 1:
|
||||
fetched_by_username_list = (
|
||||
await self.panel_service.get_users_by_filter(
|
||||
username=panel_username_on_panel_standard
|
||||
)
|
||||
)
|
||||
if fetched_by_username_list and len(fetched_by_username_list) == 1:
|
||||
panel_user_obj_from_api = fetched_by_username_list[0]
|
||||
|
||||
if not panel_user_obj_from_api:
|
||||
logging.error(
|
||||
f"Failed to create or link panel user for TG_ID {user_id} with panel username '{panel_username_on_panel_standard}'. Response: {creation_response if 'creation_response' in locals() else 'N/A'}"
|
||||
)
|
||||
await self._notify_admin_panel_user_creation_failed(user_id)
|
||||
return None, None, None, False
|
||||
|
||||
if not panel_user_obj_from_api:
|
||||
@@ -110,33 +164,47 @@ class SubscriptionService:
|
||||
f"Could not obtain panel user object for TG user {user_id} after all checks."
|
||||
)
|
||||
|
||||
return current_local_panel_uuid if current_local_panel_uuid else None, None, None, panel_user_created_or_linked_now
|
||||
return (
|
||||
current_local_panel_uuid if current_local_panel_uuid else None,
|
||||
None,
|
||||
None,
|
||||
panel_user_created_or_linked_now,
|
||||
)
|
||||
|
||||
actual_panel_uuid_from_api = panel_user_obj_from_api.get('uuid')
|
||||
actual_panel_username_from_api = panel_user_obj_from_api.get(
|
||||
'username')
|
||||
panel_telegram_id_from_api = panel_user_obj_from_api.get('telegramId')
|
||||
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
|
||||
actual_panel_username_from_api = panel_user_obj_from_api.get("username")
|
||||
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
|
||||
|
||||
if not actual_panel_uuid_from_api:
|
||||
logging.error(
|
||||
f"Panel user object for TG user {user_id} does not contain 'uuid'. Data: {panel_user_obj_from_api}"
|
||||
)
|
||||
return current_local_panel_uuid, None, None, panel_user_created_or_linked_now
|
||||
return (
|
||||
current_local_panel_uuid,
|
||||
None,
|
||||
None,
|
||||
panel_user_created_or_linked_now,
|
||||
)
|
||||
|
||||
needs_local_panel_uuid_update = False
|
||||
if current_local_panel_uuid is None and actual_panel_uuid_from_api:
|
||||
needs_local_panel_uuid_update = True
|
||||
elif current_local_panel_uuid is not None and current_local_panel_uuid != actual_panel_uuid_from_api:
|
||||
elif (
|
||||
current_local_panel_uuid is not None
|
||||
and current_local_panel_uuid != actual_panel_uuid_from_api
|
||||
):
|
||||
logging.warning(
|
||||
f"Local panel_uuid for user {user_id} ('{current_local_panel_uuid}') "
|
||||
f"differs from panel's UUID ('{actual_panel_uuid_from_api}') for their telegramId. "
|
||||
f"Will attempt to update local to panel's version.")
|
||||
f"Will attempt to update local to panel's version."
|
||||
)
|
||||
needs_local_panel_uuid_update = True
|
||||
|
||||
if needs_local_panel_uuid_update:
|
||||
|
||||
conflicting_user_record = await user_dal.get_user_by_panel_uuid(
|
||||
session, actual_panel_uuid_from_api)
|
||||
session, actual_panel_uuid_from_api
|
||||
)
|
||||
if conflicting_user_record and conflicting_user_record.user_id != user_id:
|
||||
logging.error(
|
||||
f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) "
|
||||
@@ -151,14 +219,20 @@ class SubscriptionService:
|
||||
"panel_user_uuid": actual_panel_uuid_from_api
|
||||
}
|
||||
|
||||
if actual_panel_username_from_api and actual_panel_username_from_api != panel_username_on_panel_standard and (
|
||||
if (
|
||||
actual_panel_username_from_api
|
||||
and actual_panel_username_from_api
|
||||
!= panel_username_on_panel_standard
|
||||
and (
|
||||
db_user.username is None
|
||||
or db_user.username != actual_panel_username_from_api):
|
||||
update_data_for_local_user[
|
||||
"username"] = actual_panel_username_from_api
|
||||
or db_user.username != actual_panel_username_from_api
|
||||
)
|
||||
):
|
||||
update_data_for_local_user["username"] = (
|
||||
actual_panel_username_from_api
|
||||
)
|
||||
|
||||
await user_dal.update_user(session, user_id,
|
||||
update_data_for_local_user)
|
||||
await user_dal.update_user(session, user_id, update_data_for_local_user)
|
||||
db_user.panel_user_uuid = actual_panel_uuid_from_api
|
||||
if "username" in update_data_for_local_user:
|
||||
db_user.username = update_data_for_local_user["username"]
|
||||
@@ -175,69 +249,79 @@ class SubscriptionService:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if panel_user_obj_from_api and current_local_panel_uuid and panel_telegram_id_int != user_id:
|
||||
if (
|
||||
panel_user_obj_from_api
|
||||
and current_local_panel_uuid
|
||||
and panel_telegram_id_int != user_id
|
||||
):
|
||||
logging.info(
|
||||
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'."
|
||||
)
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
current_local_panel_uuid, {"telegramId": user_id})
|
||||
current_local_panel_uuid, {"telegramId": user_id}
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_obj_from_api.get(
|
||||
'subscriptionUuid') or panel_user_obj_from_api.get('shortUuid')
|
||||
panel_short_uuid = panel_user_obj_from_api.get('shortUuid')
|
||||
"subscriptionUuid"
|
||||
) or panel_user_obj_from_api.get("shortUuid")
|
||||
panel_short_uuid = panel_user_obj_from_api.get("shortUuid")
|
||||
|
||||
if not panel_sub_link_id and current_local_panel_uuid:
|
||||
logging.warning(
|
||||
f"No subscriptionUuid or shortUuid found on panel for panel_user_uuid {current_local_panel_uuid} (TG ID: {user_id})."
|
||||
)
|
||||
|
||||
return current_local_panel_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_or_linked_now
|
||||
return (
|
||||
current_local_panel_uuid,
|
||||
panel_sub_link_id,
|
||||
panel_short_uuid,
|
||||
panel_user_created_or_linked_now,
|
||||
)
|
||||
|
||||
async def activate_trial_subscription(
|
||||
self, session: AsyncSession,
|
||||
user_id: int) -> Optional[Dict[str, Any]]:
|
||||
self, session: AsyncSession, user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0:
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
"message_key": "trial_feature_disabled"
|
||||
"message_key": "trial_feature_disabled",
|
||||
}
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
f"User {user_id} not found in DB, cannot activate trial.")
|
||||
logging.error(f"User {user_id} not found in DB, cannot activate trial.")
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
"message_key": "user_not_found_for_trial"
|
||||
"message_key": "user_not_found_for_trial",
|
||||
}
|
||||
|
||||
if await self.has_had_any_subscription(session, user_id):
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
"message_key": "trial_already_had_subscription_or_trial"
|
||||
"message_key": "trial_already_had_subscription_or_trial",
|
||||
}
|
||||
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = await self._get_or_create_panel_user_link_details(
|
||||
session, user_id, db_user)
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = (
|
||||
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
|
||||
)
|
||||
|
||||
if not panel_user_uuid or not panel_sub_link_id:
|
||||
logging.error(
|
||||
f"Failed to get panel link details for trial user {user_id}.")
|
||||
logging.error(f"Failed to get panel link details for trial user {user_id}.")
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": False,
|
||||
"message_key": "trial_activation_failed_panel_link"
|
||||
"message_key": "trial_activation_failed_panel_link",
|
||||
}
|
||||
|
||||
start_date = datetime.now(timezone.utc)
|
||||
end_date = start_date + timedelta(
|
||||
days=self.settings.TRIAL_DURATION_DAYS)
|
||||
end_date = start_date + timedelta(days=self.settings.TRIAL_DURATION_DAYS)
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id)
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
trial_sub_data = {
|
||||
"user_id": user_id,
|
||||
@@ -255,34 +339,34 @@ class SubscriptionService:
|
||||
except Exception as e_upsert:
|
||||
logging.error(
|
||||
f"Failed to upsert trial subscription for user {user_id}: {e_upsert}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": False,
|
||||
"message_key": "trial_activation_failed_db"
|
||||
"message_key": "trial_activation_failed_db",
|
||||
}
|
||||
|
||||
panel_update_payload: Dict[str, Any] = {
|
||||
"uuid":
|
||||
panel_user_uuid,
|
||||
"expireAt":
|
||||
end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
||||
"status":
|
||||
"ACTIVE",
|
||||
"trafficLimitBytes":
|
||||
self.settings.trial_traffic_limit_bytes,
|
||||
"trafficLimitStrategy":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
||||
"uuid": panel_user_uuid,
|
||||
"expireAt": end_date.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": self.settings.trial_traffic_limit_bytes,
|
||||
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
|
||||
}
|
||||
if self.settings.parsed_default_panel_user_inbound_uuids:
|
||||
panel_update_payload[
|
||||
"activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids
|
||||
if self.settings.parsed_user_inbound_uuids:
|
||||
panel_update_payload["activeUserInbounds"] = (
|
||||
self.settings.parsed_user_inbound_uuids
|
||||
)
|
||||
elif panel_user_created_now:
|
||||
panel_update_payload["activateAllInbounds"] = True
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload)
|
||||
panel_user_uuid, panel_update_payload
|
||||
)
|
||||
if not updated_panel_user or updated_panel_user.get("error"):
|
||||
logging.warning(
|
||||
f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}"
|
||||
@@ -291,14 +375,13 @@ class SubscriptionService:
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": False,
|
||||
"message_key": "trial_activation_failed_panel_update"
|
||||
"message_key": "trial_activation_failed_panel_update",
|
||||
}
|
||||
|
||||
await session.commit()
|
||||
|
||||
final_subscription_url = updated_panel_user.get('subscriptionUrl')
|
||||
final_panel_short_uuid = updated_panel_user.get(
|
||||
'shortUuid', panel_short_uuid)
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
return {
|
||||
"eligible": True,
|
||||
@@ -308,7 +391,7 @@ class SubscriptionService:
|
||||
"traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_short_uuid": final_panel_short_uuid,
|
||||
"subscription_url": final_subscription_url
|
||||
"subscription_url": final_subscription_url,
|
||||
}
|
||||
|
||||
async def activate_subscription(
|
||||
@@ -318,7 +401,8 @@ class SubscriptionService:
|
||||
months: int,
|
||||
payment_amount: float,
|
||||
payment_db_id: int,
|
||||
promo_code_id_from_payment: Optional[int] = None
|
||||
promo_code_id_from_payment: Optional[int] = None,
|
||||
provider: str = "yookassa",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -328,8 +412,9 @@ class SubscriptionService:
|
||||
)
|
||||
return None
|
||||
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = await self._get_or_create_panel_user_link_details(
|
||||
session, user_id, db_user)
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = (
|
||||
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
|
||||
)
|
||||
|
||||
if not panel_user_uuid or not panel_sub_link_id:
|
||||
logging.error(
|
||||
@@ -338,9 +423,14 @@ class SubscriptionService:
|
||||
return None
|
||||
|
||||
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid)
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
start_date = datetime.now(timezone.utc)
|
||||
if current_active_sub and current_active_sub.end_date and current_active_sub.end_date > start_date:
|
||||
if (
|
||||
current_active_sub
|
||||
and current_active_sub.end_date
|
||||
and current_active_sub.end_date > start_date
|
||||
):
|
||||
start_date = current_active_sub.end_date
|
||||
|
||||
duration_days_total = months * 30
|
||||
@@ -348,8 +438,13 @@ class SubscriptionService:
|
||||
|
||||
if promo_code_id_from_payment:
|
||||
promo_model = await promo_code_dal.get_promo_code_by_id(
|
||||
session, promo_code_id_from_payment)
|
||||
if promo_model and promo_model.is_active and promo_model.current_activations < promo_model.max_activations:
|
||||
session, promo_code_id_from_payment
|
||||
)
|
||||
if (
|
||||
promo_model
|
||||
and promo_model.is_active
|
||||
and promo_model.current_activations < promo_model.max_activations
|
||||
):
|
||||
applied_promo_bonus_days = promo_model.bonus_days
|
||||
duration_days_total += applied_promo_bonus_days
|
||||
|
||||
@@ -357,10 +452,12 @@ class SubscriptionService:
|
||||
session,
|
||||
promo_code_id_from_payment,
|
||||
user_id,
|
||||
payment_id=payment_db_id)
|
||||
payment_id=payment_db_id,
|
||||
)
|
||||
if activation:
|
||||
await promo_code_dal.increment_promo_code_usage(
|
||||
session, promo_code_id_from_payment)
|
||||
session, promo_code_id_from_payment
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Promo code {promo_code_id_from_payment} was already activated by user {user_id}, but bonus applied via payment {payment_db_id}."
|
||||
@@ -373,7 +470,8 @@ class SubscriptionService:
|
||||
|
||||
final_end_date = start_date + timedelta(days=duration_days_total)
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id)
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": user_id,
|
||||
@@ -384,48 +482,48 @@ class SubscriptionService:
|
||||
"duration_months": months,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE",
|
||||
"traffic_limit_bytes":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
"provider": provider,
|
||||
"skip_notifications": provider == "tribute",
|
||||
}
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||
session, sub_payload)
|
||||
session, sub_payload
|
||||
)
|
||||
except Exception as e_upsert_sub:
|
||||
logging.error(
|
||||
f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
panel_update_payload = {
|
||||
"uuid":
|
||||
panel_user_uuid,
|
||||
"expireAt":
|
||||
final_end_date.isoformat(timespec='milliseconds').replace(
|
||||
'+00:00', 'Z'),
|
||||
"status":
|
||||
"ACTIVE",
|
||||
"trafficLimitBytes":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
"trafficLimitStrategy":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY
|
||||
"uuid": panel_user_uuid,
|
||||
"expireAt": final_end_date.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": self.settings.user_traffic_limit_bytes,
|
||||
"trafficLimitStrategy": self.settings.USER_TRAFFIC_STRATEGY,
|
||||
}
|
||||
if self.settings.parsed_default_panel_user_inbound_uuids:
|
||||
panel_update_payload[
|
||||
"activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids
|
||||
if self.settings.parsed_user_inbound_uuids:
|
||||
panel_update_payload["activeUserInbounds"] = (
|
||||
self.settings.parsed_user_inbound_uuids
|
||||
)
|
||||
elif panel_user_created_now:
|
||||
panel_update_payload["activateAllInbounds"] = True
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload)
|
||||
panel_user_uuid, panel_update_payload
|
||||
)
|
||||
if not updated_panel_user or updated_panel_user.get("error"):
|
||||
logging.warning(
|
||||
f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}"
|
||||
)
|
||||
return None
|
||||
|
||||
final_subscription_url = updated_panel_user.get('subscriptionUrl')
|
||||
final_panel_short_uuid = updated_panel_user.get(
|
||||
'shortUuid', panel_short_uuid)
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
@@ -434,48 +532,84 @@ class SubscriptionService:
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_short_uuid": final_panel_short_uuid,
|
||||
"subscription_url": final_subscription_url,
|
||||
"applied_promo_bonus_days": applied_promo_bonus_days
|
||||
"applied_promo_bonus_days": applied_promo_bonus_days,
|
||||
}
|
||||
|
||||
async def extend_active_subscription_days(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
bonus_days: int,
|
||||
reason: str = "bonus") -> Optional[datetime]:
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
bonus_days: int,
|
||||
reason: str = "bonus",
|
||||
) -> Optional[datetime]:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user or not user.panel_user_uuid:
|
||||
if not user:
|
||||
logging.warning(
|
||||
f"Cannot extend subscription for user {user_id}: User or panel_user_uuid not found."
|
||||
f"Cannot extend subscription for user {user_id}: user not found."
|
||||
)
|
||||
return None
|
||||
|
||||
panel_uuid, panel_sub_uuid, _, _ = await self._get_or_create_panel_user_link_details(
|
||||
session, user_id, user
|
||||
)
|
||||
if not panel_uuid or not panel_sub_uuid:
|
||||
logging.error(
|
||||
f"Failed to ensure panel user for subscription extension of user {user_id}."
|
||||
)
|
||||
return None
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, user.panel_user_uuid)
|
||||
session, user_id, panel_uuid
|
||||
)
|
||||
if not active_sub or not active_sub.end_date:
|
||||
logging.info(
|
||||
f"No active extendable subscription found for user {user_id} (panel: {user.panel_user_uuid}) for reason: {reason}."
|
||||
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days."
|
||||
)
|
||||
return None
|
||||
start_date = datetime.now(timezone.utc)
|
||||
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
||||
bonus_sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"panel_subscription_uuid": panel_sub_uuid,
|
||||
"start_date": start_date,
|
||||
"end_date": new_end_date_obj,
|
||||
"duration_months": 0,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||
}
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_uuid
|
||||
)
|
||||
updated_sub_model = await subscription_dal.upsert_subscription(
|
||||
session, bonus_sub_payload
|
||||
)
|
||||
else:
|
||||
current_end_date = active_sub.end_date
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
start_point_for_bonus = (
|
||||
current_end_date if current_end_date > now_utc else now_utc
|
||||
)
|
||||
new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days)
|
||||
|
||||
current_end_date = active_sub.end_date
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc
|
||||
new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days)
|
||||
|
||||
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
||||
session, active_sub.subscription_id, new_end_date_obj)
|
||||
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
||||
session, active_sub.subscription_id, new_end_date_obj
|
||||
)
|
||||
|
||||
if updated_sub_model:
|
||||
panel_update_success = await self.panel_service.update_user_details_on_panel(
|
||||
user.panel_user_uuid, {
|
||||
"expireAt":
|
||||
new_end_date_obj.isoformat(
|
||||
timespec='milliseconds').replace('+00:00', 'Z')
|
||||
})
|
||||
panel_update_success = (
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
{
|
||||
"expireAt": new_end_date_obj.isoformat(
|
||||
timespec="milliseconds"
|
||||
).replace("+00:00", "Z")
|
||||
},
|
||||
)
|
||||
)
|
||||
if not panel_update_success:
|
||||
logging.warning(
|
||||
f"Panel expiry update failed for {user.panel_user_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}."
|
||||
f"Panel expiry update failed for {panel_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}."
|
||||
)
|
||||
|
||||
logging.info(
|
||||
@@ -489,8 +623,8 @@ class SubscriptionService:
|
||||
return None
|
||||
|
||||
async def get_active_subscription_details(
|
||||
self, session: AsyncSession,
|
||||
user_id: int) -> Optional[Dict[str, Any]]:
|
||||
self, session: AsyncSession, user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or not db_user.panel_user_uuid:
|
||||
logging.info(
|
||||
@@ -500,136 +634,131 @@ class SubscriptionService:
|
||||
|
||||
panel_user_uuid = db_user.panel_user_uuid
|
||||
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid)
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(
|
||||
panel_user_uuid)
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
||||
|
||||
if not panel_user_data:
|
||||
logging.warning(
|
||||
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Using local data if available."
|
||||
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage."
|
||||
)
|
||||
if local_active_sub and local_active_sub.end_date and local_active_sub.end_date > datetime.now(
|
||||
timezone.utc):
|
||||
return {
|
||||
"end_date":
|
||||
local_active_sub.end_date,
|
||||
"status_from_panel":
|
||||
(local_active_sub.status_from_panel or "UNKNOWN") +
|
||||
" (Panel Unreachable)",
|
||||
"config_link":
|
||||
await self.panel_service.get_subscription_link(
|
||||
local_active_sub.panel_subscription_uuid)
|
||||
if local_active_sub.panel_subscription_uuid else "N/A",
|
||||
"traffic_limit_bytes":
|
||||
local_active_sub.traffic_limit_bytes,
|
||||
"traffic_used_bytes":
|
||||
local_active_sub.traffic_used_bytes,
|
||||
"user_bot_username":
|
||||
db_user.username,
|
||||
"is_panel_data":
|
||||
False
|
||||
}
|
||||
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||
return None
|
||||
|
||||
if local_active_sub:
|
||||
update_payload_local = {}
|
||||
panel_status = panel_user_data.get('status', 'UNKNOWN').upper()
|
||||
panel_expire_at_str = panel_user_data.get('expireAt')
|
||||
panel_traffic_used = panel_user_data.get('usedTrafficBytes')
|
||||
panel_traffic_limit = panel_user_data.get('trafficLimitBytes')
|
||||
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
||||
panel_expire_at_str = panel_user_data.get("expireAt")
|
||||
panel_traffic_used = panel_user_data.get("usedTrafficBytes")
|
||||
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
||||
panel_sub_uuid_from_panel = panel_user_data.get(
|
||||
'subscriptionUuid') or panel_user_data.get('shortUuid')
|
||||
"subscriptionUuid"
|
||||
) or panel_user_data.get("shortUuid")
|
||||
|
||||
if local_active_sub.status_from_panel != panel_status:
|
||||
update_payload_local['status_from_panel'] = panel_status
|
||||
update_payload_local["status_from_panel"] = panel_status
|
||||
if panel_expire_at_str:
|
||||
panel_expire_dt = datetime.fromisoformat(
|
||||
panel_expire_at_str.replace("Z", "+00:00"))
|
||||
panel_expire_at_str.replace("Z", "+00:00")
|
||||
)
|
||||
if local_active_sub.end_date.replace(
|
||||
microsecond=0) != panel_expire_dt.replace(
|
||||
microsecond=0):
|
||||
update_payload_local['end_date'] = panel_expire_dt
|
||||
update_payload_local['last_notification_sent'] = None
|
||||
if panel_traffic_used is not None and local_active_sub.traffic_used_bytes != panel_traffic_used:
|
||||
update_payload_local['traffic_used_bytes'] = panel_traffic_used
|
||||
if panel_traffic_limit is not None and local_active_sub.traffic_limit_bytes != panel_traffic_limit:
|
||||
update_payload_local[
|
||||
'traffic_limit_bytes'] = panel_traffic_limit
|
||||
if panel_sub_uuid_from_panel and local_active_sub.panel_subscription_uuid != panel_sub_uuid_from_panel:
|
||||
update_payload_local[
|
||||
'panel_subscription_uuid'] = panel_sub_uuid_from_panel
|
||||
microsecond=0
|
||||
) != panel_expire_dt.replace(microsecond=0):
|
||||
update_payload_local["end_date"] = panel_expire_dt
|
||||
update_payload_local["last_notification_sent"] = None
|
||||
if (
|
||||
panel_traffic_used is not None
|
||||
and local_active_sub.traffic_used_bytes != panel_traffic_used
|
||||
):
|
||||
update_payload_local["traffic_used_bytes"] = panel_traffic_used
|
||||
if (
|
||||
panel_traffic_limit is not None
|
||||
and local_active_sub.traffic_limit_bytes != panel_traffic_limit
|
||||
):
|
||||
update_payload_local["traffic_limit_bytes"] = panel_traffic_limit
|
||||
if (
|
||||
panel_sub_uuid_from_panel
|
||||
and local_active_sub.panel_subscription_uuid
|
||||
!= panel_sub_uuid_from_panel
|
||||
):
|
||||
update_payload_local["panel_subscription_uuid"] = (
|
||||
panel_sub_uuid_from_panel
|
||||
)
|
||||
|
||||
is_active_based_on_panel = panel_status == "ACTIVE" and (
|
||||
panel_expire_dt > datetime.now(timezone.utc)
|
||||
if panel_expire_dt else False)
|
||||
if panel_expire_dt
|
||||
else False
|
||||
)
|
||||
if local_active_sub.is_active != is_active_based_on_panel:
|
||||
update_payload_local['is_active'] = is_active_based_on_panel
|
||||
update_payload_local["is_active"] = is_active_based_on_panel
|
||||
|
||||
if update_payload_local:
|
||||
await subscription_dal.update_subscription(
|
||||
session, local_active_sub.subscription_id,
|
||||
update_payload_local)
|
||||
session, local_active_sub.subscription_id, update_payload_local
|
||||
)
|
||||
|
||||
panel_end_date = datetime.fromisoformat(
|
||||
panel_user_data['expireAt'].replace(
|
||||
"Z", "+00:00")) if panel_user_data.get('expireAt') else None
|
||||
panel_end_date = (
|
||||
datetime.fromisoformat(panel_user_data["expireAt"].replace("Z", "+00:00"))
|
||||
if panel_user_data.get("expireAt")
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"end_date":
|
||||
panel_end_date,
|
||||
"status_from_panel":
|
||||
panel_user_data.get('status', 'UNKNOWN').upper(),
|
||||
"config_link":
|
||||
panel_user_data.get('subscriptionUrl')
|
||||
or (await self.panel_service.get_subscription_link(
|
||||
panel_user_data['shortUuid'])
|
||||
if panel_user_data.get('shortUuid') else "N/A"),
|
||||
"traffic_limit_bytes":
|
||||
panel_user_data.get('trafficLimitBytes'),
|
||||
"traffic_used_bytes":
|
||||
panel_user_data.get('usedTrafficBytes'),
|
||||
"user_bot_username":
|
||||
db_user.username,
|
||||
"is_panel_data":
|
||||
True
|
||||
"end_date": panel_end_date,
|
||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||
"config_link": panel_user_data.get("subscriptionUrl"),
|
||||
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
}
|
||||
|
||||
async def get_subscriptions_ending_soon(
|
||||
self, session: AsyncSession,
|
||||
days_threshold: int) -> List[Dict[str, Any]]:
|
||||
subs_models_with_users = await subscription_dal.get_subscriptions_near_expiration(
|
||||
session, days_threshold)
|
||||
self, session: AsyncSession, days_threshold: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
subs_models_with_users = (
|
||||
await subscription_dal.get_subscriptions_near_expiration(
|
||||
session, days_threshold
|
||||
)
|
||||
)
|
||||
results = []
|
||||
for sub_model in subs_models_with_users:
|
||||
if sub_model.user and sub_model.end_date:
|
||||
days_left = (sub_model.end_date - datetime.now(
|
||||
timezone.utc)).total_seconds() / (24 * 3600)
|
||||
results.append({
|
||||
'user_id':
|
||||
sub_model.user_id,
|
||||
'first_name':
|
||||
sub_model.user.first_name or f"User {sub_model.user_id}",
|
||||
'language_code':
|
||||
sub_model.user.language_code
|
||||
or self.settings.DEFAULT_LANGUAGE,
|
||||
'end_date_str':
|
||||
sub_model.end_date.strftime('%Y-%m-%d'),
|
||||
'days_left':
|
||||
max(0, int(round(days_left))),
|
||||
'subscription_end_date_iso_for_update':
|
||||
sub_model.end_date
|
||||
})
|
||||
if (
|
||||
sub_model.user
|
||||
and sub_model.end_date
|
||||
and not sub_model.skip_notifications
|
||||
):
|
||||
days_left = (
|
||||
sub_model.end_date - datetime.now(timezone.utc)
|
||||
).total_seconds() / (24 * 3600)
|
||||
results.append(
|
||||
{
|
||||
"user_id": sub_model.user_id,
|
||||
"first_name": sub_model.user.first_name
|
||||
or f"User {sub_model.user_id}",
|
||||
"language_code": sub_model.user.language_code
|
||||
or self.settings.DEFAULT_LANGUAGE,
|
||||
"end_date_str": sub_model.end_date.strftime("%Y-%m-%d"),
|
||||
"days_left": max(0, int(round(days_left))),
|
||||
"subscription_end_date_iso_for_update": sub_model.end_date,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
async def update_last_notification_sent(self, session: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_end_date: datetime):
|
||||
sub_to_update = await subscription_dal.find_subscription_for_notification_update(
|
||||
session, user_id, subscription_end_date)
|
||||
async def update_last_notification_sent(
|
||||
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
||||
):
|
||||
sub_to_update = (
|
||||
await subscription_dal.find_subscription_for_notification_update(
|
||||
session, user_id, subscription_end_date
|
||||
)
|
||||
)
|
||||
if sub_to_update:
|
||||
await subscription_dal.update_subscription_notification_time(
|
||||
session, sub_to_update.subscription_id,
|
||||
datetime.now(timezone.utc))
|
||||
session, sub_to_update.subscription_id, datetime.now(timezone.utc)
|
||||
)
|
||||
logging.info(
|
||||
f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import 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.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from .notification_service import notify_admin_new_payment
|
||||
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
||||
from db.dal import payment_dal, user_dal, subscription_dal
|
||||
|
||||
|
||||
def convert_period_to_months(period: Optional[str]) -> int:
|
||||
"""Map Tribute subscription period strings to months."""
|
||||
if not period:
|
||||
return 1
|
||||
|
||||
mapping = {
|
||||
"monthly": 1,
|
||||
"quarterly": 3,
|
||||
"3-month": 3,
|
||||
"3months": 3,
|
||||
"3-months": 3,
|
||||
"q": 3,
|
||||
"halfyearly": 6,
|
||||
"yearly": 12,
|
||||
"annual": 12,
|
||||
"y": 12,
|
||||
}
|
||||
return mapping.get(period.lower(), 1)
|
||||
|
||||
|
||||
class TributeService:
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes,
|
||||
signature_header: Optional[str]) -> web.Response:
|
||||
settings = self.settings
|
||||
bot = self.bot
|
||||
i18n = self.i18n
|
||||
async_session_factory = self.async_session_factory
|
||||
subscription_service = self.subscription_service
|
||||
referral_service = self.referral_service
|
||||
|
||||
if settings.TRIBUTE_API_KEY:
|
||||
if not signature_header:
|
||||
return web.Response(status=403, text="no_signature")
|
||||
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
|
||||
hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
except Exception:
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
event_name = payload.get('name')
|
||||
data = payload.get('payload', {})
|
||||
user_id = data.get('telegram_user_id')
|
||||
price_val = (
|
||||
data.get('amount')
|
||||
or data.get('amount_paid')
|
||||
or data.get('price')
|
||||
)
|
||||
|
||||
if not user_id or price_val is None:
|
||||
return web.Response(status=200, text="ok_missing_fields")
|
||||
|
||||
period_val = data.get('period')
|
||||
months = convert_period_to_months(period_val)
|
||||
price_rub = price_val / 100
|
||||
|
||||
async with async_session_factory() as session:
|
||||
if event_name == 'new_subscription':
|
||||
provider_payment_id = str(data.get('subscription_id'))
|
||||
existing_payment = await payment_dal.get_payment_by_provider_payment_id(
|
||||
session, provider_payment_id)
|
||||
if existing_payment:
|
||||
logging.info(
|
||||
"Duplicate Tribute payment webhook ignored for provider_payment_id %s",
|
||||
provider_payment_id,
|
||||
)
|
||||
payment_record = existing_payment
|
||||
else:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
'user_id': user_id,
|
||||
'amount': float(price_rub),
|
||||
'currency': 'RUB',
|
||||
'status': 'succeeded',
|
||||
'description': 'Tribute subscription',
|
||||
'subscription_duration_months': months,
|
||||
'provider_payment_id': provider_payment_id,
|
||||
'provider': 'tribute',
|
||||
},
|
||||
)
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
float(price_rub),
|
||||
payment_record.payment_id,
|
||||
provider='tribute',
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session, user_id, months)
|
||||
await session.commit()
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
|
||||
final_end = (referral_bonus.get('referee_new_end_date')
|
||||
if referral_bonus else None)
|
||||
if not final_end:
|
||||
final_end = activation_details.get('end_date')
|
||||
|
||||
if final_end:
|
||||
config_link = activation_details.get("subscription_url") or _(
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if applied_ref_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 and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus_full",
|
||||
months=months,
|
||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_ref_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
config_link=config_link,
|
||||
)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful_full",
|
||||
months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'),
|
||||
config_link=config_link,
|
||||
)
|
||||
markup = get_connect_and_main_keyboard(
|
||||
lang, i18n, settings, config_link
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
user_id,
|
||||
success_msg,
|
||||
reply_markup=markup,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send Tribute payment success message to user {user_id}: {e}")
|
||||
|
||||
await notify_admin_new_payment(
|
||||
bot,
|
||||
settings,
|
||||
i18n,
|
||||
user_id,
|
||||
months,
|
||||
float(price_rub),
|
||||
currency="RUB",
|
||||
)
|
||||
else:
|
||||
await session.commit()
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
|
||||
async def tribute_webhook_route(request: web.Request):
|
||||
"""AIOHTTP route handler for Tribute webhook calls."""
|
||||
tribute_service: TributeService = request.app['tribute_service']
|
||||
raw_body = await request.read()
|
||||
signature_header = request.headers.get('trbt-signature')
|
||||
return await tribute_service.handle_webhook(raw_body, signature_header)
|
||||
@@ -6,6 +6,7 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_broadcast_message = State()
|
||||
confirming_broadcast = State()
|
||||
waiting_for_promo_details = State()
|
||||
waiting_for_promo_edit_details = State()
|
||||
waiting_for_user_id_to_ban = State()
|
||||
waiting_for_user_id_to_unban = State()
|
||||
|
||||
|
||||
+131
-26
@@ -26,7 +26,6 @@ class Settings(BaseSettings):
|
||||
|
||||
YOOKASSA_SHOP_ID: Optional[str] = None
|
||||
YOOKASSA_SECRET_KEY: Optional[str] = None
|
||||
YOOKASSA_WEBHOOK_BASE_URL: Optional[str] = None
|
||||
YOOKASSA_RETURN_URL: Optional[str] = None
|
||||
|
||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
|
||||
@@ -34,16 +33,45 @@ class Settings(BaseSettings):
|
||||
YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment")
|
||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||
|
||||
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
|
||||
WEBHOOK_BASE_URL: Optional[str] = None
|
||||
|
||||
PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
CRYPTOPAY_TOKEN: Optional[str] = None
|
||||
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
|
||||
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
|
||||
CRYPTOPAY_ASSET: str = Field(default="RUB")
|
||||
CRYPTOPAY_ENABLED: bool = Field(default=True)
|
||||
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7)
|
||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9)
|
||||
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC: int = Field(default=0)
|
||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||
STARS_ENABLED: bool = Field(default=True)
|
||||
TRIBUTE_ENABLED: bool = Field(default=True)
|
||||
|
||||
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
||||
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
||||
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
|
||||
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
|
||||
|
||||
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
|
||||
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
|
||||
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
|
||||
|
||||
SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
|
||||
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
|
||||
|
||||
REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
|
||||
default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH")
|
||||
@@ -65,10 +93,9 @@ class Settings(BaseSettings):
|
||||
|
||||
PANEL_API_URL: Optional[str] = None
|
||||
PANEL_API_KEY: Optional[str] = None
|
||||
PANEL_USER_DEFAULT_EXPIRE_DAYS: int = Field(default=1)
|
||||
PANEL_USER_DEFAULT_TRAFFIC_BYTES: int = Field(default=0)
|
||||
PANEL_USER_DEFAULT_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
PANEL_USER_DEFAULT_INBOUND_UUIDS: Optional[str] = Field(
|
||||
USER_TRAFFIC_LIMIT_GB: Optional[float] = Field(default=0.0)
|
||||
USER_TRAFFIC_STRATEGY: str = Field(default="NO_RESET")
|
||||
USER_INBOUND_UUIDS: Optional[str] = Field(
|
||||
default=None,
|
||||
description=
|
||||
"Comma-separated UUIDs of inbounds to activate for new panel users")
|
||||
@@ -81,6 +108,10 @@ class Settings(BaseSettings):
|
||||
WEB_SERVER_PORT: int = Field(default=8080)
|
||||
LOGS_PAGE_SIZE: int = Field(default=10)
|
||||
|
||||
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
|
||||
|
||||
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
@@ -118,11 +149,18 @@ class Settings(BaseSettings):
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_default_panel_user_inbound_uuids(self) -> Optional[List[str]]:
|
||||
if self.PANEL_USER_DEFAULT_INBOUND_UUIDS:
|
||||
def user_traffic_limit_bytes(self) -> int:
|
||||
if self.USER_TRAFFIC_LIMIT_GB is None or self.USER_TRAFFIC_LIMIT_GB <= 0:
|
||||
return 0
|
||||
return int(self.USER_TRAFFIC_LIMIT_GB * (1024**3))
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def parsed_user_inbound_uuids(self) -> Optional[List[str]]:
|
||||
if self.USER_INBOUND_UUIDS:
|
||||
return [
|
||||
uuid.strip()
|
||||
for uuid in self.PANEL_USER_DEFAULT_INBOUND_UUIDS.split(',')
|
||||
for uuid in self.USER_INBOUND_UUIDS.split(',')
|
||||
if uuid.strip()
|
||||
]
|
||||
return None
|
||||
@@ -136,9 +174,48 @@ class Settings(BaseSettings):
|
||||
@computed_field
|
||||
@property
|
||||
def yookassa_full_webhook_url(self) -> Optional[str]:
|
||||
if self.YOOKASSA_WEBHOOK_BASE_URL:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
|
||||
return None
|
||||
|
||||
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}"
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_webhook_path(self) -> str:
|
||||
return "/webhook/tribute"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def panel_webhook_path(self) -> str:
|
||||
return "/webhook/panel"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def panel_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.panel_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def cryptopay_webhook_path(self) -> str:
|
||||
return "/webhook/cryptopay"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def cryptopay_full_webhook_url(self) -> Optional[str]:
|
||||
base = self.WEBHOOK_BASE_URL
|
||||
if base:
|
||||
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@@ -146,16 +223,44 @@ class Settings(BaseSettings):
|
||||
def subscription_options(self) -> Dict[int, float]:
|
||||
options: Dict[int, float] = {}
|
||||
|
||||
if self.PRICE_1_MONTH is not None:
|
||||
options[1] = float(self.PRICE_1_MONTH / 100.0)
|
||||
if self.PRICE_3_MONTHS is not None:
|
||||
options[3] = float(self.PRICE_3_MONTHS / 100.0)
|
||||
if self.PRICE_6_MONTHS is not None:
|
||||
options[6] = float(self.PRICE_6_MONTHS / 100.0)
|
||||
if self.PRICE_12_MONTHS is not None:
|
||||
options[12] = float(self.PRICE_12_MONTHS / 100.0)
|
||||
if self.MONTH_1_ENABLED and self.RUB_PRICE_1_MONTH is not None:
|
||||
options[1] = float(self.RUB_PRICE_1_MONTH)
|
||||
if self.MONTH_3_ENABLED and self.RUB_PRICE_3_MONTHS is not None:
|
||||
options[3] = float(self.RUB_PRICE_3_MONTHS)
|
||||
if self.MONTH_6_ENABLED and self.RUB_PRICE_6_MONTHS is not None:
|
||||
options[6] = float(self.RUB_PRICE_6_MONTHS)
|
||||
if self.MONTH_12_ENABLED and self.RUB_PRICE_12_MONTHS is not None:
|
||||
options[12] = float(self.RUB_PRICE_12_MONTHS)
|
||||
return options
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def stars_subscription_options(self) -> Dict[int, int]:
|
||||
options: Dict[int, int] = {}
|
||||
if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None:
|
||||
options[1] = self.STARS_PRICE_1_MONTH
|
||||
if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None:
|
||||
options[3] = self.STARS_PRICE_3_MONTHS
|
||||
if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None:
|
||||
options[6] = self.STARS_PRICE_6_MONTHS
|
||||
if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None:
|
||||
options[12] = self.STARS_PRICE_12_MONTHS
|
||||
return options
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_payment_links(self) -> Dict[int, str]:
|
||||
links: Dict[int, str] = {}
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
|
||||
links[1] = self.TRIBUTE_LINK_1_MONTH
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
|
||||
links[3] = self.TRIBUTE_LINK_3_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
|
||||
links[6] = self.TRIBUTE_LINK_6_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
|
||||
links[12] = self.TRIBUTE_LINK_12_MONTHS
|
||||
return links
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def referral_bonus_inviter(self) -> Dict[int, int]:
|
||||
|
||||
@@ -46,6 +46,15 @@ async def get_payment_by_yookassa_id(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_payment_by_provider_payment_id(
|
||||
session: AsyncSession, provider_payment_id: str) -> Optional[Payment]:
|
||||
"""Fetch a payment by provider-specific identifier."""
|
||||
stmt = select(Payment).where(
|
||||
Payment.provider_payment_id == provider_payment_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_payment_by_db_id(session: AsyncSession,
|
||||
payment_db_id: int) -> Optional[Payment]:
|
||||
|
||||
@@ -87,6 +96,19 @@ async def update_payment_status_by_db_id(
|
||||
return payment
|
||||
|
||||
|
||||
async def user_has_successful_payment_for_provider(
|
||||
session: AsyncSession, user_id: int, provider: str) -> bool:
|
||||
"""Check if a user has at least one successful payment for the provider."""
|
||||
|
||||
stmt = (select(Payment.payment_id)
|
||||
.where(Payment.user_id == user_id,
|
||||
Payment.provider == provider,
|
||||
Payment.status == 'succeeded')
|
||||
.limit(1))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def update_payment_status_by_yk_id(session: AsyncSession,
|
||||
yookassa_payment_id: str,
|
||||
new_status: str) -> Optional[Payment]:
|
||||
@@ -113,3 +135,23 @@ async def get_recent_payment_logs_with_user(session: AsyncSession,
|
||||
Payment.created_at.desc()).limit(limit).offset(offset))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_provider_payment_and_status(
|
||||
session: AsyncSession, payment_db_id: int,
|
||||
provider_payment_id: str, new_status: str) -> Optional[Payment]:
|
||||
payment = await get_payment_by_db_id(session, payment_db_id)
|
||||
if payment:
|
||||
payment.status = new_status
|
||||
payment.provider_payment_id = provider_payment_id
|
||||
payment.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(payment)
|
||||
logging.info(
|
||||
f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment record with DB ID {payment_db_id} not found for provider update."
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -49,6 +49,27 @@ async def get_all_active_promo_codes(session: AsyncSession,
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_promo_code(session: AsyncSession, promo_id: int,
|
||||
update_data: Dict[str, Any]) -> Optional[PromoCode]:
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None
|
||||
for key, value in update_data.items():
|
||||
setattr(promo, key, value)
|
||||
await session.flush()
|
||||
await session.refresh(promo)
|
||||
return promo
|
||||
|
||||
|
||||
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None
|
||||
await session.delete(promo)
|
||||
await session.flush()
|
||||
return promo
|
||||
|
||||
|
||||
async def increment_promo_code_usage(
|
||||
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
|
||||
promo = await get_promo_code_by_id(session, promo_code_id)
|
||||
|
||||
@@ -129,6 +129,21 @@ async def deactivate_other_active_subscriptions(
|
||||
)
|
||||
|
||||
|
||||
async def deactivate_all_user_subscriptions(
|
||||
session: AsyncSession, user_id: int) -> int:
|
||||
stmt = (
|
||||
update(Subscription)
|
||||
.where(Subscription.user_id == user_id, Subscription.is_active == True)
|
||||
.values(is_active=False, status_from_panel="INACTIVE_USER_NOT_FOUND")
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount > 0:
|
||||
logging.info(
|
||||
f"Deactivated {result.rowcount} subscriptions for user {user_id} due to missing panel user."
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def update_subscription_end_date(
|
||||
session: AsyncSession, subscription_id: int,
|
||||
new_end_date: datetime) -> Optional[Subscription]:
|
||||
@@ -156,7 +171,9 @@ async def get_subscriptions_near_expiration(
|
||||
threshold_date = now_utc + timedelta(days=days_threshold)
|
||||
|
||||
stmt = (select(Subscription).join(Subscription.user).where(
|
||||
Subscription.is_active == True, Subscription.end_date > now_utc,
|
||||
Subscription.is_active == True,
|
||||
Subscription.skip_notifications == False,
|
||||
Subscription.end_date > now_utc,
|
||||
Subscription.end_date <= threshold_date,
|
||||
or_(
|
||||
Subscription.last_notification_sent == None,
|
||||
@@ -203,3 +220,36 @@ async def find_subscription_for_notification_update(
|
||||
<= subscription_end_date_to_match + timedelta(seconds=1)).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_skip_notifications_for_provider(
|
||||
session: AsyncSession, user_id: int, provider: str,
|
||||
skip: bool) -> int:
|
||||
stmt = (update(Subscription).where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.is_active == True,
|
||||
Subscription.provider == provider).values(skip_notifications=skip))
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def get_active_subscriptions_for_autorenew(
|
||||
session: AsyncSession, provider: str,
|
||||
days_threshold: int = 1,
|
||||
require_skip_flag: bool = True) -> List[Subscription]:
|
||||
"""Fetch active subscriptions nearing expiration for auto-renew logic."""
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
threshold_date = now_utc + timedelta(days=days_threshold)
|
||||
|
||||
conditions = [
|
||||
Subscription.provider == provider,
|
||||
Subscription.is_active == True,
|
||||
Subscription.end_date <= threshold_date,
|
||||
]
|
||||
if require_skip_flag:
|
||||
conditions.append(Subscription.skip_notifications == True)
|
||||
|
||||
stmt = select(Subscription).where(*conditions)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
+66
-39
@@ -9,33 +9,47 @@ from datetime import datetime
|
||||
from ..models import User, Subscription
|
||||
|
||||
|
||||
async def get_user_by_id(session: AsyncSession,
|
||||
user_id: int) -> Optional[User]:
|
||||
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||
stmt = select(User).where(User.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_username(session: AsyncSession,
|
||||
username: str) -> Optional[User]:
|
||||
clean_username = username.lstrip('@').lower()
|
||||
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||||
clean_username = username.lstrip("@").lower()
|
||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_panel_uuid(session: AsyncSession,
|
||||
panel_uuid: str) -> Optional[User]:
|
||||
async def get_user_by_panel_uuid(
|
||||
session: AsyncSession, panel_uuid: str
|
||||
) -> Optional[User]:
|
||||
stmt = select(User).where(User.panel_user_uuid == panel_uuid)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_user(session: AsyncSession, user_data: Dict[str,
|
||||
Any]) -> User:
|
||||
async def get_user(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
panel_uuid: Optional[str] = None,
|
||||
) -> Optional[User]:
|
||||
if user_id is not None:
|
||||
return await get_user_by_id(session, user_id)
|
||||
if username is not None:
|
||||
return await get_user_by_username(session, username)
|
||||
if panel_uuid is not None:
|
||||
return await get_user_by_panel_uuid(session, panel_uuid)
|
||||
return None
|
||||
|
||||
if 'registration_date' not in user_data:
|
||||
user_data['registration_date'] = datetime.now()
|
||||
|
||||
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> User:
|
||||
|
||||
if "registration_date" not in user_data:
|
||||
user_data["registration_date"] = datetime.now()
|
||||
|
||||
new_user = User(**user_data)
|
||||
session.add(new_user)
|
||||
@@ -47,8 +61,9 @@ async def create_user(session: AsyncSession, user_data: Dict[str,
|
||||
return new_user
|
||||
|
||||
|
||||
async def update_user(session: AsyncSession, user_id: int,
|
||||
update_data: Dict[str, Any]) -> Optional[User]:
|
||||
async def update_user(
|
||||
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
||||
) -> Optional[User]:
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if user:
|
||||
for key, value in update_data.items():
|
||||
@@ -58,16 +73,17 @@ async def update_user(session: AsyncSession, user_id: int,
|
||||
return user
|
||||
|
||||
|
||||
async def update_user_language(session: AsyncSession, user_id: int,
|
||||
lang_code: str) -> bool:
|
||||
stmt = update(User).where(User.user_id == user_id).values(
|
||||
language_code=lang_code)
|
||||
async def update_user_language(
|
||||
session: AsyncSession, user_id: int, lang_code: str
|
||||
) -> bool:
|
||||
stmt = update(User).where(User.user_id == user_id).values(language_code=lang_code)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def set_user_ban_status(session: AsyncSession, user_id: int,
|
||||
is_banned: bool) -> bool:
|
||||
async def set_user_ban_status(
|
||||
session: AsyncSession, user_id: int, is_banned: bool
|
||||
) -> bool:
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if user:
|
||||
user.is_banned = is_banned
|
||||
@@ -77,23 +93,27 @@ async def set_user_ban_status(session: AsyncSession, user_id: int,
|
||||
return False
|
||||
|
||||
|
||||
async def get_banned_users_paginated(session: AsyncSession, limit: int,
|
||||
offset: int) -> Tuple[List[User], int]:
|
||||
stmt_users = select(User).where(User.is_banned == True).order_by(
|
||||
User.registration_date.desc()).limit(limit).offset(offset)
|
||||
async def get_banned_users_paginated(
|
||||
session: AsyncSession, limit: int, offset: int
|
||||
) -> Tuple[List[User], int]:
|
||||
stmt_users = (
|
||||
select(User)
|
||||
.where(User.is_banned == True)
|
||||
.order_by(User.registration_date.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result_users = await session.execute(stmt_users)
|
||||
users_list = result_users.scalars().all()
|
||||
|
||||
stmt_count = select(
|
||||
func.count()).select_from(User).where(User.is_banned == True)
|
||||
stmt_count = select(func.count()).select_from(User).where(User.is_banned == True)
|
||||
result_count = await session.execute(stmt_count)
|
||||
total_banned = result_count.scalar_one()
|
||||
|
||||
return users_list, total_banned
|
||||
|
||||
|
||||
async def get_all_active_user_ids_for_broadcast(
|
||||
session: AsyncSession) -> List[int]:
|
||||
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)
|
||||
return result.scalars().all()
|
||||
@@ -101,24 +121,31 @@ async def get_all_active_user_ids_for_broadcast(
|
||||
|
||||
async def get_user_count_stats_dal(session: AsyncSession) -> Dict[str, int]:
|
||||
total_users_stmt = select(func.count(User.user_id)).select_from(User)
|
||||
banned_users_stmt = select(func.count(
|
||||
User.user_id)).select_from(User).where(User.is_banned == True)
|
||||
banned_users_stmt = (
|
||||
select(func.count(User.user_id)).select_from(User).where(User.is_banned == True)
|
||||
)
|
||||
|
||||
active_subs_stmt = (select(func.count(
|
||||
func.distinct(Subscription.user_id))).join(
|
||||
User, Subscription.user_id == User.user_id).where(
|
||||
Subscription.is_active == True).where(
|
||||
Subscription.end_date > datetime.now()))
|
||||
active_subs_stmt = (
|
||||
select(func.count(func.distinct(Subscription.user_id)))
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(Subscription.is_active == True)
|
||||
.where(Subscription.end_date > datetime.now())
|
||||
)
|
||||
|
||||
total_users = (await
|
||||
session.execute(total_users_stmt)).scalar_one_or_none() or 0
|
||||
banned_users = (
|
||||
await session.execute(banned_users_stmt)).scalar_one_or_none() or 0
|
||||
total_users = (await session.execute(total_users_stmt)).scalar_one_or_none() or 0
|
||||
banned_users = (await session.execute(banned_users_stmt)).scalar_one_or_none() or 0
|
||||
active_subs_users = (
|
||||
await session.execute(active_subs_stmt)).scalar_one_or_none() or 0
|
||||
await session.execute(active_subs_stmt)
|
||||
).scalar_one_or_none() or 0
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"banned_users": banned_users,
|
||||
"users_with_active_subscriptions": active_subs_users,
|
||||
}
|
||||
|
||||
|
||||
async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
|
||||
stmt = select(User).where(User.panel_user_uuid.is_not(None))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -70,6 +70,8 @@ class Subscription(Base):
|
||||
traffic_limit_bytes = Column(BigInteger, nullable=True)
|
||||
traffic_used_bytes = Column(BigInteger, nullable=True)
|
||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||
provider = Column(String, nullable=True)
|
||||
skip_notifications = Column(Boolean, default=False)
|
||||
|
||||
user = relationship("User", back_populates="subscriptions")
|
||||
|
||||
@@ -89,6 +91,8 @@ class Payment(Base):
|
||||
unique=True,
|
||||
index=True,
|
||||
nullable=True)
|
||||
provider_payment_id = Column(String, unique=True, nullable=True)
|
||||
provider = Column(String, nullable=False, default="yookassa", index=True)
|
||||
idempotence_key = Column(String, unique=True, nullable=True)
|
||||
amount = Column(Float, nullable=False)
|
||||
currency = Column(String, nullable=False)
|
||||
|
||||
+11
-10
@@ -6,26 +6,27 @@ services:
|
||||
hostname: remnawave-tg-shop
|
||||
env_file:
|
||||
- .env
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
# volumes:
|
||||
# - ./locales:/app/locales
|
||||
networks:
|
||||
- remnawave-network
|
||||
volumes:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
hostname: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||
# networks:
|
||||
# - remnawave-network
|
||||
networks:
|
||||
- remnawave-network
|
||||
restart: unless-stopped
|
||||
|
||||
# networks:
|
||||
# remnawave-network:
|
||||
# external: true
|
||||
networks:
|
||||
remnawave-network:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
remnawave-tg-shop-db-data:
|
||||
|
||||
+29
-8
@@ -22,9 +22,13 @@
|
||||
"select_subscription_period": "Select subscription period:",
|
||||
"no_subscription_options_available": "No subscription options available at the moment.",
|
||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||
"confirm_subscription_prompt": "Confirm subscription purchase:\nDuration: {months} mo.\nPrice: {price} {currency_symbol}",
|
||||
"choose_payment_method": "Choose payment method:",
|
||||
"pay_button": "💳 Pay",
|
||||
"confirm_payment_button": "✅ Yes ({price} {currency_symbol})",
|
||||
"pay_with_yookassa_button": "💳 YooKassa",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
"connect_button": "🔗 Connect",
|
||||
"cancel_button": "❌ Cancel",
|
||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||
"payment_service_unavailable": "Payment service temporarily unavailable. Please try again later.",
|
||||
@@ -33,10 +37,10 @@
|
||||
"error_payment_gateway_link_failed": "Failed to get payment link. Please contact support.",
|
||||
"payment_link_message": "To pay for {months} mo. subscription, click the button below:",
|
||||
"error_payment_gateway": "Payment gateway error. Please try again later or contact support.",
|
||||
"payment_successful": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.",
|
||||
"payment_successful_with_promo": "✅ Payment successful!\nYour {months}-month subscription (with promo bonus +{bonus_days} days) is active until {end_date}.",
|
||||
"payment_successful_with_referral_bonus": "✅ 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}.",
|
||||
"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_promo_full": "✅ Payment successful!\nYour {months}-month subscription (with promo bonus +{bonus_days} days) 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.",
|
||||
"error_processing_your_payment": "An error occurred processing your payment after success. Please contact support with the details.",
|
||||
|
||||
@@ -54,7 +58,7 @@
|
||||
"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>.",
|
||||
"promo_code_no_active_subscription": "You must have an active subscription to apply this promo code.",
|
||||
"promo_code_applied_success": "✅ Promo code <code>{code}</code> applied successfully!\nYour subscription is extended by {bonus_days} days and is now active until {new_end_date}.",
|
||||
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
|
||||
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
|
||||
"promo_input_cancelled_short": "Promo code entry cancelled.",
|
||||
|
||||
@@ -88,6 +92,7 @@
|
||||
"admin_stats_button": "📊 Statistics",
|
||||
"admin_broadcast_button": "📢 Broadcast",
|
||||
"admin_create_promo_button": "🎁 Create Promo",
|
||||
"admin_manage_promos_button": "🛠 Manage Promos",
|
||||
"admin_view_promos_button": "👀 Promo List",
|
||||
"admin_ban_user_button": "🚫 Ban User",
|
||||
"admin_unban_user_button": "✅ Unban User",
|
||||
@@ -139,6 +144,13 @@
|
||||
"admin_promo_list_item": "<code>{code}</code>: +{bonus}d, {current}/{max} used, valid until {valid_until}",
|
||||
"admin_promo_valid_indefinitely": "indefinite",
|
||||
"admin_promo_valid_until_display": "until {date}",
|
||||
"admin_manage_promos_title": "Manage Promo Codes",
|
||||
"admin_promo_edit_button": "✏️ Edit",
|
||||
"admin_promo_delete_button": "🗑 Delete",
|
||||
"admin_promo_edit_prompt": "Send new details for <code>{code}</code> in format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
|
||||
"admin_promo_updated_success": "Promo <code>{code}</code> updated.",
|
||||
"admin_promo_deleted_success": "Promo <code>{code}</code> deleted.",
|
||||
"admin_promo_not_found": "Promo not found.",
|
||||
|
||||
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
|
||||
"admin_user_not_found_in_bot_db": "User <code>{user_id}</code> not found in bot database.",
|
||||
@@ -166,7 +178,7 @@
|
||||
"user_card_sub_na": "n/a",
|
||||
"user_card_ban_button": "🚫 Ban",
|
||||
"user_card_unban_button": "✅ Unban",
|
||||
"user_card_back_to_banned_list": "⬅️ Back to Ban List",
|
||||
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
||||
"admin_confirm_action_title": "Confirm: {action_text}",
|
||||
"ban_verb_l": "ban",
|
||||
"unban_verb_l": "unban",
|
||||
@@ -196,11 +208,20 @@
|
||||
"sync_completed_with_errors_details": "Checked: {total_checked} entries.\nUsers synced/updated: {users_synced}.\nSubscriptions synced/updated: {subs_synced}.\nErrors: {errors_count}.\n\nFirst errors:\n{error_details_preview}",
|
||||
"no_errors_placeholder": "none",
|
||||
"admin_sync_initiated_from_panel": "Sync initiated...",
|
||||
"admin_panel_user_creation_failed": "❌ Failed to create panel user for TG ID {user_id}. Panel unreachable?",
|
||||
"error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.",
|
||||
"error_displaying_statistics": "Error displaying statistics.",
|
||||
"stub_page_display": "Page",
|
||||
|
||||
"subscription_ending_soon_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription ends on {end_date} (in {days_left} days).\n\nTo avoid interruption, please renew it in the main menu.",
|
||||
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.",
|
||||
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
|
||||
|
||||
"admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.",
|
||||
"admin_new_payment_notification": "\ud83d\udcb3 Payment received from user {user_id}: {months} mo. for {amount} {currency}.",
|
||||
"admin_promo_activation_notification": "\ud83c\udf81 Promo code {code} activated by user {user_id} (+{bonus_days}d).",
|
||||
|
||||
"error_unknown": "An unknown error occurred."
|
||||
}
|
||||
|
||||
+30
-9
@@ -22,9 +22,13 @@
|
||||
"select_subscription_period": "Выберите срок подписки:",
|
||||
"no_subscription_options_available": "В данный момент нет доступных вариантов подписки.",
|
||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||
"confirm_subscription_prompt": "Подтвердите покупку подписки:\nСрок: {months} мес.\nЦена: {price} {currency_symbol}",
|
||||
"choose_payment_method": "Выберите способ оплаты:",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"confirm_payment_button": "✅ Да ({price} {currency_symbol})",
|
||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||
"pay_with_cryptopay_button": "💎 CryptoBot",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
"connect_button": "🔗 Подключиться",
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.",
|
||||
@@ -33,10 +37,10 @@
|
||||
"error_payment_gateway_link_failed": "Не удалось получить ссылку на оплату. Пожалуйста, свяжитесь с поддержкой.",
|
||||
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
|
||||
"error_payment_gateway": "Ошибка платежного шлюза. Попробуйте позже или свяжитесь с поддержкой.",
|
||||
"payment_successful": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.",
|
||||
"payment_successful_with_promo": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (с учетом промокода на +{bonus_days} дней) активна до {end_date}.",
|
||||
"payment_successful_with_referral_bonus": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.",
|
||||
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
|
||||
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"payment_successful_with_promo_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (с учетом промокода на +{bonus_days} дней) активна до {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": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
|
||||
"error_processing_your_payment": "Произошла ошибка при обработке вашего платежа после его успеха. Пожалуйста, свяжитесь с поддержкой, указав детали.",
|
||||
|
||||
@@ -54,7 +58,7 @@
|
||||
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
|
||||
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
|
||||
"promo_code_no_active_subscription": "Для активации этого промокода у вас должна быть активная подписка.",
|
||||
"promo_code_applied_success": "✅ Промокод <code>{code}</code> успешно применен!\nВаша подписка продлена на {bonus_days} дней и теперь активна до {new_end_date}.",
|
||||
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
|
||||
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
|
||||
"promo_input_cancelled_short": "Ввод промокода отменен.",
|
||||
|
||||
@@ -88,7 +92,8 @@
|
||||
"admin_stats_button": "📊 Статистика",
|
||||
"admin_broadcast_button": "📢 Рассылка",
|
||||
"admin_create_promo_button": "🎁 Создать промо",
|
||||
"admin_view_promos_button": "👀 Спиок промо",
|
||||
"admin_manage_promos_button": "🛠 Управление промо",
|
||||
"admin_view_promos_button": "👀 Список промо",
|
||||
"admin_ban_user_button": "🚫 Забанить",
|
||||
"admin_unban_user_button": "✅ Разбанить",
|
||||
"admin_view_banned_users_button": "📜 Бан-лист",
|
||||
@@ -139,6 +144,13 @@
|
||||
"admin_promo_list_item": "<code>{code}</code>: +{bonus}дн, {current}/{max} акт., до {valid_until}",
|
||||
"admin_promo_valid_indefinitely": "бессрочно",
|
||||
"admin_promo_valid_until_display": "до {date}",
|
||||
"admin_manage_promos_title": "Управление промокодами",
|
||||
"admin_promo_edit_button": "✏️ Изменить",
|
||||
"admin_promo_delete_button": "🗑 Удалить",
|
||||
"admin_promo_edit_prompt": "Отправьте новые данные для <code>{code}</code> в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК]",
|
||||
"admin_promo_updated_success": "Промокод <code>{code}</code> обновлен.",
|
||||
"admin_promo_deleted_success": "Промокод <code>{code}</code> удален.",
|
||||
"admin_promo_not_found": "Промокод не найден.",
|
||||
|
||||
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
||||
"admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",
|
||||
@@ -166,7 +178,7 @@
|
||||
"user_card_sub_na": "н/д",
|
||||
"user_card_ban_button": "🚫 Заблокировать",
|
||||
"user_card_unban_button": "✅ Разблокировать",
|
||||
"user_card_back_to_banned_list": "⬅️ К списку забаненных",
|
||||
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||
"admin_confirm_action_title": "Подтверждение: {action_text}",
|
||||
"ban_verb_l": "блокировка",
|
||||
"unban_verb_l": "разблокировка",
|
||||
@@ -196,11 +208,20 @@
|
||||
"sync_completed_with_errors_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.\nОшибок: {errors_count}.\n\nПервые ошибки:\n{error_details_preview}",
|
||||
"no_errors_placeholder": "нет",
|
||||
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
|
||||
"admin_panel_user_creation_failed": "❌ Не удалось создать пользователя на панели для TG ID {user_id}. Панель недоступна?",
|
||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||
"stub_page_display": "Страница",
|
||||
|
||||
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
|
||||
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
|
||||
|
||||
"admin_new_trial_notification": "\ud83c\udf21 Пользователь {user_id} активировал пробный период до {end_date}.",
|
||||
"admin_new_payment_notification": "\ud83d\udcb3 Получен платеж от пользователя {user_id}: {months} мес. за {amount} {currency}.",
|
||||
"admin_promo_activation_notification": "\ud83c\udf81 Пользователь {user_id} активировал промокод {code} (+{bonus_days} дн.)",
|
||||
|
||||
"error_unknown": "Произошла неизвестная ошибка."
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,11 +1,11 @@
|
||||
aiogram==3.20.0
|
||||
aiogram==3.21.0
|
||||
python-dotenv==1.0.1
|
||||
aiohttp==3.9.5
|
||||
aiohttp==3.12.14
|
||||
pydantic==2.7.1
|
||||
yookassa==3.5.0
|
||||
pycountry==23.12.11
|
||||
apscheduler==3.10.4
|
||||
pydantic_settings
|
||||
sqlalchemy[asyncio]==2.0.29
|
||||
asyncpg==0.29.0
|
||||
alembic==1.13.1
|
||||
alembic==1.13.1
|
||||
aiocryptopay==0.4.8
|
||||
|
||||
Reference in New Issue
Block a user