Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
dc8ba3ce06 |
+27
-5
@@ -31,11 +31,33 @@ YOOKASSA_PAYMENT_SUBJECT=payment
|
|||||||
# If unset, bot will use polling while YooKassa uses webhooks.
|
# If unset, bot will use polling while YooKassa uses webhooks.
|
||||||
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||||
|
|
||||||
# Subscription Prices (integer values)
|
# Payment Methods
|
||||||
PRICE_1_MONTH=150
|
YOOKASSA_ENABLED=True
|
||||||
PRICE_3_MONTHS=300
|
STARS_ENABLED=True
|
||||||
PRICE_6_MONTHS=500
|
TRIBUTE_ENABLED=True
|
||||||
PRICE_12_MONTHS=900
|
|
||||||
|
# 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=
|
||||||
|
# API key for verifying Tribute webhook signatures
|
||||||
|
TRIBUTE_API_KEY=
|
||||||
|
|
||||||
# Subscription Expiration Notifications
|
# Subscription Expiration Notifications
|
||||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7
|
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ on:
|
|||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- 'v*.*.*'
|
- 'v*.*.*'
|
||||||
|
paths-ignore:
|
||||||
|
- 'README.md'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
|||||||
+11
-2
@@ -1,11 +1,20 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN rm -rf /root/.cache
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
CMD ["python", "main.py"]
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ This Telegram bot is designed to automate the sale and management of subscriptio
|
|||||||
* **Python 3.11**
|
* **Python 3.11**
|
||||||
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
|
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
|
||||||
* **aiohttp:** For running the webhook server
|
* **aiohttp:** For running the webhook server
|
||||||
* **aiosqlite:** Asynchronous SQLite database interaction
|
* **sqlalchemy:** Asynchronous PostgreSQL database interaction
|
||||||
* **YooKassa SDK:** For payment processing
|
* **YooKassa SDK:** For payment processing
|
||||||
* **APScheduler:** For scheduled tasks (e.g., notifications)
|
* **APScheduler:** For scheduled tasks (e.g., notifications)
|
||||||
* **Pydantic:** For settings management (loading from `.env`)
|
* **Pydantic:** For settings management (loading from `.env`)
|
||||||
@@ -93,9 +93,12 @@ This Telegram bot is designed to automate the sale and management of subscriptio
|
|||||||
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
|
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
|
||||||
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
|
* `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.
|
* `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.
|
* **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_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 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_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_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.
|
||||||
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
|
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
|
||||||
@@ -103,26 +106,26 @@ This Telegram bot is designed to automate the sale and management of subscriptio
|
|||||||
* `LOGS_PAGE_SIZE`: For admin panel log pagination.
|
* `LOGS_PAGE_SIZE`: For admin panel log pagination.
|
||||||
|
|
||||||
3. **Locales:**
|
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
|
```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):**
|
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.
|
* **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`).
|
* Forward requests for `https://{YOOKASSA_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`).
|
||||||
* 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>`.
|
* If using Telegram webhooks, forward requests for `https://{TELEGRAM_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 `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`.
|
* **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`.
|
||||||
|
|
||||||
6. **Database:**
|
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:**
|
7. **Viewing Logs:**
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f vpn-shop
|
docker compose logs -f remnawave-tg-shop
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🐳 Docker Setup
|
## 🐳 Docker Setup
|
||||||
@@ -154,31 +157,39 @@ CMD ["python", "main.py"]
|
|||||||
```
|
```
|
||||||
|
|
||||||
### `docker-compose.yml`
|
### `docker-compose.yml`
|
||||||
|
|
||||||
YAML
|
|
||||||
|
|
||||||
```
|
```
|
||||||
services:
|
services:
|
||||||
vpn-shop:
|
remnawave-tg-shop:
|
||||||
build: .
|
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
|
||||||
container_name: vpn-shop
|
# build: .
|
||||||
hostname: vpn-shop
|
container_name: remnawave-tg-shop
|
||||||
networks:
|
hostname: remnawave-tg-shop
|
||||||
- remnawave-network # Ensure this external network exists or define it
|
env_file:
|
||||||
volumes:
|
- .env
|
||||||
- ./bot_database.sqlite3:/app/bot_database.sqlite3
|
# networks:
|
||||||
# - ./locales:/app/locales
|
# - remnawave-network
|
||||||
|
# volumes:
|
||||||
|
# - ./locales:/app/locales
|
||||||
restart: unless-stopped
|
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:
|
postgres:
|
||||||
remnawave-network:
|
image: postgres:17
|
||||||
external: true # Assumes 'remnawave-network' is an existing external Docker network
|
container_name: remnawave-tg-shop-db
|
||||||
# If not, you might want to define it here or use a default bridge.
|
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.
|
**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 +207,21 @@ networks:
|
|||||||
│ ├── states/ # FSM states
|
│ ├── states/ # FSM states
|
||||||
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
|
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
|
||||||
├── config/
|
├── config/
|
||||||
│ └── settings.py # Pydantic settings model
|
│ └── settings.py # Pydantic settings and config parser
|
||||||
├── db/
|
├── db/
|
||||||
│ └── database.py # Database schema, connection, and CRUD functions
|
│ ├── dal/ # Data Access Layer (queries, transactions)
|
||||||
├── locales/ # Localization files (en.json, ru.json)
|
│ ├── database_setup.py # DB connection/init setup
|
||||||
├── .env.example # Example environment variables
|
│ └── models.py # ORM models (e.g., SQLAlchemy)
|
||||||
├── .env # Your local environment variables (ignored by git)
|
├── locales/ # Localization files
|
||||||
├── Dockerfile # Instructions to build the Docker image
|
│ ├── en.json # English locale
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
│ └── ru.json # Russian locale
|
||||||
├── requirements.txt # Python dependencies
|
├── .env.example # Example environment variables for local setup
|
||||||
└── main.py # Main entry point to run the bot
|
├── .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
|
## 🤝 Contributing
|
||||||
|
|||||||
+113
-88
@@ -11,16 +11,23 @@ from config.settings import Settings
|
|||||||
from db.dal import user_dal, message_log_dal
|
from db.dal import user_dal, message_log_dal
|
||||||
|
|
||||||
from bot.states.admin_states import AdminStates
|
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
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
|
||||||
router = Router(name="admin_broadcast_router")
|
router = Router(name="admin_broadcast_router")
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
async def broadcast_message_prompt_handler(
|
||||||
state: FSMContext, i18n_data: dict,
|
callback: types.CallbackQuery,
|
||||||
settings: Settings,
|
state: FSMContext,
|
||||||
session: AsyncSession):
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not i18n:
|
if not i18n:
|
||||||
@@ -35,25 +42,28 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
|||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
prompt_text,
|
prompt_text,
|
||||||
reply_markup=get_back_to_admin_panel_keyboard(
|
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||||
current_lang, i18n))
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Could not edit message for broadcast prompt: {e}. Sending new."
|
f"Could not edit message for broadcast prompt: {e}. Sending new."
|
||||||
)
|
)
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
prompt_text,
|
prompt_text,
|
||||||
reply_markup=get_back_to_admin_panel_keyboard(
|
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||||
current_lang, i18n))
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
|
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
|
||||||
async def process_broadcast_message_handler(message: types.Message,
|
async def process_broadcast_message_handler(
|
||||||
state: FSMContext, i18n_data: dict,
|
message: types.Message,
|
||||||
settings: Settings,
|
state: FSMContext,
|
||||||
session: AsyncSession):
|
i18n_data: dict,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not i18n:
|
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)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
broadcast_message_text = message.html_text
|
# Сохраняем в state исходный текст и entities
|
||||||
await state.update_data(broadcast_message=broadcast_message_text)
|
text = message.text or message.caption or ""
|
||||||
|
entities = message.entities or message.caption_entities or []
|
||||||
|
|
||||||
preview_snippet = (
|
await state.update_data(
|
||||||
broadcast_message_text[:200] +
|
broadcast_text=text,
|
||||||
"...") if len(broadcast_message_text) > 200 else broadcast_message_text
|
broadcast_entities=entities,
|
||||||
confirmation_prompt = _("admin_broadcast_confirm_prompt",
|
)
|
||||||
message_preview=preview_snippet)
|
|
||||||
|
|
||||||
await message.answer(confirmation_prompt,
|
preview_snippet = (text[:200] + "...") if len(text) > 200 else text
|
||||||
reply_markup=get_broadcast_confirmation_keyboard(
|
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
|
||||||
current_lang, i18n),
|
|
||||||
parse_mode="HTML")
|
await message.answer(
|
||||||
|
confirmation_prompt,
|
||||||
|
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n),
|
||||||
|
)
|
||||||
await state.set_state(AdminStates.confirming_broadcast)
|
await state.set_state(AdminStates.confirming_broadcast)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "admin_action:main",
|
@router.callback_query(
|
||||||
AdminStates.waiting_for_broadcast_message)
|
F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
|
||||||
async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
)
|
||||||
state: FSMContext,
|
async def cancel_broadcast_at_prompt_stage(
|
||||||
settings: Settings, i18n_data: dict,
|
callback: types.CallbackQuery,
|
||||||
session: AsyncSession):
|
state: FSMContext,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not i18n or not callback.message:
|
if not i18n or not callback.message:
|
||||||
@@ -94,96 +111,104 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
|
_("admin_broadcast_cancelled_nav_back"), reply_markup=None
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
|
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
|
||||||
|
|
||||||
await callback.answer(_("admin_broadcast_cancelled_alert"))
|
await callback.answer(_("admin_broadcast_cancelled_alert"))
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
await callback.message.answer(_(key="admin_panel_title"),
|
await callback.message.answer(
|
||||||
reply_markup=get_admin_panel_keyboard(
|
_(key="admin_panel_title"),
|
||||||
i18n, current_lang, settings))
|
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("broadcast_final_action:"),
|
@router.callback_query(
|
||||||
AdminStates.confirming_broadcast)
|
F.data.startswith("broadcast_final_action:"),
|
||||||
async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
|
AdminStates.confirming_broadcast,
|
||||||
state: FSMContext,
|
)
|
||||||
i18n_data: dict, bot: Bot,
|
async def confirm_broadcast_callback_handler(
|
||||||
settings: Settings,
|
callback: types.CallbackQuery,
|
||||||
session: AsyncSession):
|
state: FSMContext,
|
||||||
|
i18n_data: dict,
|
||||||
|
bot: Bot,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not i18n or not callback.message:
|
if not i18n or not callback.message:
|
||||||
await callback.answer("Error processing broadcast confirmation.",
|
await callback.answer("Error processing broadcast confirmation.", show_alert=True)
|
||||||
show_alert=True)
|
|
||||||
return
|
return
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
action = callback.data.split(":")[1]
|
action = callback.data.split(":")[1]
|
||||||
user_fsm_data = await state.get_data()
|
user_fsm_data = await state.get_data()
|
||||||
broadcast_message_to_send = user_fsm_data.get("broadcast_message")
|
|
||||||
|
|
||||||
if action == "send":
|
if action == "send":
|
||||||
if not broadcast_message_to_send:
|
text = user_fsm_data.get("broadcast_text")
|
||||||
await callback.message.edit_text(
|
entities = user_fsm_data.get("broadcast_entities", [])
|
||||||
_("admin_broadcast_error_no_message"))
|
|
||||||
|
if not text:
|
||||||
|
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
|
||||||
await state.clear()
|
await state.clear()
|
||||||
await callback.answer(_("admin_broadcast_error_no_message_alert"),
|
await callback.answer(
|
||||||
show_alert=True)
|
_("admin_broadcast_error_no_message_alert"), show_alert=True
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await callback.message.edit_text(_("admin_broadcast_sending_started"),
|
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
|
||||||
reply_markup=None)
|
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast(
|
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||||
session)
|
|
||||||
|
|
||||||
sent_count = 0
|
sent_count = 0
|
||||||
failed_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
|
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:
|
try:
|
||||||
await bot.send_message(user_id_to_send,
|
await bot.send_message(
|
||||||
broadcast_message_to_send,
|
chat_id=uid,
|
||||||
parse_mode="HTML")
|
text=text,
|
||||||
|
entities=entities,
|
||||||
|
)
|
||||||
sent_count += 1
|
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(
|
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:
|
except Exception as e:
|
||||||
failed_count += 1
|
failed_count += 1
|
||||||
logging.warning(
|
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(
|
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)
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -192,17 +217,17 @@ async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
|
|||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||||
|
|
||||||
result_message = _("admin_broadcast_finished_stats",
|
result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count)
|
||||||
sent_count=sent_count,
|
|
||||||
failed_count=failed_count)
|
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
result_message,
|
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":
|
elif action == "cancel":
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_("admin_broadcast_cancelled"),
|
_("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 callback.answer()
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ router = Router(name="admin_common_router")
|
|||||||
@router.message(Command("admin"))
|
@router.message(Command("admin"))
|
||||||
async def admin_panel_command_handler(
|
async def admin_panel_command_handler(
|
||||||
message: types.Message,
|
message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
i18n_data: dict,
|
i18n_data: dict,
|
||||||
):
|
):
|
||||||
@@ -34,6 +35,7 @@ async def admin_panel_command_handler(
|
|||||||
await message.answer("Language service error.")
|
await message.answer("Language service error.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
await message.answer(_(key="admin_panel_title"),
|
await message.answer(_(key="admin_panel_title"),
|
||||||
reply_markup=get_admin_panel_keyboard(
|
reply_markup=get_admin_panel_keyboard(
|
||||||
@@ -73,6 +75,9 @@ async def admin_panel_actions_callback_handler(
|
|||||||
elif action == "create_promo":
|
elif action == "create_promo":
|
||||||
await admin_promo_handlers.create_promo_prompt_handler(
|
await admin_promo_handlers.create_promo_prompt_handler(
|
||||||
callback, state, i18n_data, settings, session)
|
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":
|
elif action == "view_promos":
|
||||||
await admin_promo_handlers.view_promo_codes_handler(
|
await admin_promo_handlers.view_promo_codes_handler(
|
||||||
callback, i18n_data, settings, session)
|
callback, i18n_data, settings, session)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from aiogram import Router, F, types, Bot
|
from aiogram import Router, F, types, Bot
|
||||||
|
from aiogram.filters import StateFilter
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -11,6 +12,7 @@ from db.dal import promo_code_dal
|
|||||||
|
|
||||||
from bot.states.admin_states import AdminStates
|
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 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
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
|
||||||
router = Router(name="admin_promo_codes_router")
|
router = Router(name="admin_promo_codes_router")
|
||||||
@@ -194,8 +196,152 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
|
|||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "admin_action:main",
|
@router.callback_query(F.data == "admin_action:manage_promos")
|
||||||
AdminStates.waiting_for_promo_details)
|
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,
|
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||||
state: FSMContext,
|
state: FSMContext,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
|||||||
+142
-102
@@ -16,11 +16,14 @@ router = Router(name="admin_sync_router")
|
|||||||
|
|
||||||
|
|
||||||
@router.message(Command("sync"))
|
@router.message(Command("sync"))
|
||||||
async def sync_command_handler(message_event: Union[types.Message,
|
async def sync_command_handler(
|
||||||
types.CallbackQuery],
|
message_event: Union[types.Message, types.CallbackQuery],
|
||||||
bot: Bot, settings: Settings, i18n_data: dict,
|
bot: Bot,
|
||||||
panel_service: PanelApiService,
|
settings: Settings,
|
||||||
session: AsyncSession):
|
i18n_data: dict,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not i18n:
|
if not i18n:
|
||||||
@@ -33,14 +36,15 @@ async def sync_command_handler(message_event: Union[types.Message,
|
|||||||
return
|
return
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
target_chat_id = message_event.chat.id if isinstance(
|
target_chat_id = (
|
||||||
message_event, types.Message) else (
|
message_event.chat.id
|
||||||
message_event.message.chat.id if message_event.message else None)
|
if isinstance(message_event, types.Message)
|
||||||
|
else (message_event.message.chat.id if message_event.message else None)
|
||||||
|
)
|
||||||
if not target_chat_id:
|
if not target_chat_id:
|
||||||
logging.error("Sync handler: could not determine target_chat_id.")
|
logging.error("Sync handler: could not determine target_chat_id.")
|
||||||
if isinstance(message_event, types.CallbackQuery):
|
if isinstance(message_event, types.CallbackQuery):
|
||||||
await message_event.answer("Error initiating sync.",
|
await message_event.answer("Error initiating sync.", show_alert=True)
|
||||||
show_alert=True)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(message_event, types.Message):
|
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:
|
if panel_users_data is None:
|
||||||
error_msg = "Failed to fetch users from panel or panel API issue."
|
error_msg = "Failed to fetch users from panel or panel API issue."
|
||||||
sync_errors.append(error_msg)
|
sync_errors.append(error_msg)
|
||||||
await panel_sync_dal.update_panel_sync_status(
|
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
|
||||||
session, "failed", error_msg)
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await bot.send_message(target_chat_id,
|
await bot.send_message(target_chat_id, _("sync_failed", details=error_msg))
|
||||||
_("sync_failed", details=error_msg))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not panel_users_data:
|
if not panel_users_data:
|
||||||
status_msg = "No users found in the panel to sync."
|
status_msg = "No users found in the panel to sync."
|
||||||
await panel_sync_dal.update_panel_sync_status(
|
await panel_sync_dal.update_panel_sync_status(
|
||||||
session, "success", status_msg, 0, 0)
|
session, "success", status_msg, 0, 0
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await bot.send_message(
|
await bot.send_message(
|
||||||
target_chat_id,
|
target_chat_id,
|
||||||
_("sync_completed", status="Success", details=status_msg))
|
_("sync_completed", status="Success", details=status_msg),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
total_panel_users = len(panel_users_data)
|
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:
|
for panel_user_dict in panel_users_data:
|
||||||
users_processed_count += 1
|
users_processed_count += 1
|
||||||
panel_uuid = panel_user_dict.get('uuid')
|
panel_uuid = panel_user_dict.get("uuid")
|
||||||
telegram_id_from_panel_str = panel_user_dict.get('telegramId')
|
telegram_id_from_panel_str = panel_user_dict.get("telegramId")
|
||||||
panel_username = panel_user_dict.get('username')
|
panel_username = panel_user_dict.get("username")
|
||||||
|
|
||||||
if not panel_uuid:
|
if not panel_uuid:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
@@ -111,26 +115,22 @@ async def sync_command_handler(message_event: Union[types.Message,
|
|||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
bot_user = await user_dal.get_user_by_id(session,
|
bot_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||||
telegram_id_from_panel)
|
|
||||||
if not bot_user:
|
if not bot_user:
|
||||||
user_data_to_create = {
|
user_data_to_create = {
|
||||||
"user_id":
|
"user_id": telegram_id_from_panel,
|
||||||
telegram_id_from_panel,
|
"username": panel_username,
|
||||||
"username":
|
"panel_user_uuid": panel_uuid,
|
||||||
panel_username,
|
"language_code": settings.DEFAULT_LANGUAGE,
|
||||||
"panel_user_uuid":
|
"registration_date": (
|
||||||
panel_uuid,
|
datetime.fromisoformat(
|
||||||
"language_code":
|
panel_user_dict["createdAt"].replace("Z", "+00:00")
|
||||||
settings.DEFAULT_LANGUAGE,
|
)
|
||||||
"registration_date":
|
if panel_user_dict.get("createdAt")
|
||||||
datetime.fromisoformat(
|
else datetime.now(timezone.utc)
|
||||||
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,
|
bot_user = await user_dal.create_user(session, user_data_to_create)
|
||||||
user_data_to_create)
|
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Sync: Created new local user {telegram_id_from_panel} from panel data {panel_uuid}."
|
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(
|
conflicting_user = await user_dal.get_user_by_panel_uuid(
|
||||||
session, panel_uuid)
|
session, panel_uuid
|
||||||
if conflicting_user and conflicting_user.user_id != telegram_id_from_panel:
|
)
|
||||||
|
if (
|
||||||
|
conflicting_user
|
||||||
|
and conflicting_user.user_id != telegram_id_from_panel
|
||||||
|
):
|
||||||
sync_errors.append(
|
sync_errors.append(
|
||||||
f"Panel UUID {panel_uuid} for TG {telegram_id_from_panel} already linked to another TG user {conflicting_user.user_id}."
|
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
|
continue
|
||||||
|
|
||||||
await user_dal.update_user(
|
await user_dal.update_user(
|
||||||
session, telegram_id_from_panel, {
|
session,
|
||||||
"panel_user_uuid": panel_uuid,
|
telegram_id_from_panel,
|
||||||
"username": panel_username
|
{"panel_user_uuid": panel_uuid, "username": panel_username},
|
||||||
})
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Sync: Updated panel_uuid for local user {telegram_id_from_panel} to {panel_uuid}."
|
f"Sync: Updated panel_uuid for local user {telegram_id_from_panel} to {panel_uuid}."
|
||||||
)
|
)
|
||||||
|
|
||||||
panel_sub_link_id = panel_user_dict.get(
|
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:
|
if panel_sub_link_id:
|
||||||
end_date_str = panel_user_dict.get('expireAt')
|
end_date_str = panel_user_dict.get("expireAt")
|
||||||
start_date_str = panel_user_dict.get('createdAt')
|
start_date_str = panel_user_dict.get("createdAt")
|
||||||
|
|
||||||
if end_date_str:
|
if end_date_str:
|
||||||
try:
|
try:
|
||||||
end_date_obj = datetime.fromisoformat(
|
end_date_obj = datetime.fromisoformat(
|
||||||
end_date_str.replace("Z", "+00:00"))
|
end_date_str.replace("Z", "+00:00")
|
||||||
start_date_obj = datetime.fromisoformat(
|
)
|
||||||
start_date_str.replace(
|
start_date_obj = (
|
||||||
"Z",
|
datetime.fromisoformat(
|
||||||
"+00:00")) if start_date_str else datetime.now(
|
start_date_str.replace("Z", "+00:00")
|
||||||
timezone.utc)
|
)
|
||||||
|
if start_date_str
|
||||||
|
else datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
status_from_panel = panel_user_dict.get(
|
status_from_panel = panel_user_dict.get(
|
||||||
'status', 'UNKNOWN').upper()
|
"status", "UNKNOWN"
|
||||||
is_active_flag = 1 if status_from_panel == 'ACTIVE' and end_date_obj > datetime.now(
|
).upper()
|
||||||
timezone.utc) else 0
|
is_active_flag = (
|
||||||
|
1
|
||||||
|
if status_from_panel == "ACTIVE"
|
||||||
|
and end_date_obj > datetime.now(timezone.utc)
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
sub_payload = {
|
sub_payload = {
|
||||||
"user_id":
|
"user_id": telegram_id_from_panel,
|
||||||
telegram_id_from_panel,
|
"panel_user_uuid": panel_uuid,
|
||||||
"panel_user_uuid":
|
"panel_subscription_uuid": panel_sub_link_id,
|
||||||
panel_uuid,
|
"start_date": start_date_obj,
|
||||||
"panel_subscription_uuid":
|
"end_date": end_date_obj,
|
||||||
panel_sub_link_id,
|
"is_active": is_active_flag,
|
||||||
"start_date":
|
"status_from_panel": status_from_panel,
|
||||||
start_date_obj,
|
"traffic_limit_bytes": panel_user_dict.get(
|
||||||
"end_date":
|
"trafficLimitBytes"
|
||||||
end_date_obj,
|
),
|
||||||
"is_active":
|
"traffic_used_bytes": panel_user_dict.get(
|
||||||
is_active_flag,
|
"usedTrafficBytes"
|
||||||
"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(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
session, panel_uuid, panel_sub_link_id)
|
session, panel_uuid, panel_sub_link_id
|
||||||
await subscription_dal.upsert_subscription(
|
)
|
||||||
session, sub_payload)
|
await subscription_dal.upsert_subscription(session, sub_payload)
|
||||||
subscriptions_synced_count += 1
|
subscriptions_synced_count += 1
|
||||||
users_synced_successfully += 1
|
users_synced_successfully += 1
|
||||||
except ValueError as e_date:
|
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:
|
except Exception as e_sub_sync:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Sync: Error syncing subscription for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}): {e_sub_sync}",
|
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(
|
sync_errors.append(
|
||||||
f"Sub sync error for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
|
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:
|
else:
|
||||||
|
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
session, panel_uuid, None)
|
session, panel_uuid, None
|
||||||
|
)
|
||||||
logging.info(
|
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."
|
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."
|
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"
|
status_msg_key = "sync_completed_details"
|
||||||
final_status_type = "success"
|
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."
|
details_for_db = f"Successfully processed {users_processed_count} users. Synced {subscriptions_synced_count} subscriptions."
|
||||||
|
|
||||||
await panel_sync_dal.update_panel_sync_status(
|
await panel_sync_dal.update_panel_sync_status(
|
||||||
session, final_status_type, details_for_db, users_processed_count,
|
session,
|
||||||
subscriptions_synced_count)
|
final_status_type,
|
||||||
|
details_for_db,
|
||||||
|
users_processed_count,
|
||||||
|
subscriptions_synced_count,
|
||||||
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
final_user_message = _(status_msg_key,
|
final_user_message = _(
|
||||||
total_checked=total_panel_users,
|
status_msg_key,
|
||||||
users_synced=users_synced_successfully,
|
total_checked=total_panel_users,
|
||||||
subs_synced=subscriptions_synced_count,
|
users_synced=users_synced_successfully,
|
||||||
errors_count=len(sync_errors),
|
subs_synced=subscriptions_synced_count,
|
||||||
error_details_preview=error_preview
|
errors_count=len(sync_errors),
|
||||||
if sync_errors else _("no_errors_placeholder"))
|
error_details_preview=(
|
||||||
|
error_preview if sync_errors else _("no_errors_placeholder")
|
||||||
|
),
|
||||||
|
)
|
||||||
await bot.send_message(target_chat_id, final_user_message)
|
await bot.send_message(target_chat_id, final_user_message)
|
||||||
|
|
||||||
except Exception as e_sync_global:
|
except Exception as e_sync_global:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.error(f"Global error during /sync command: {e_sync_global}",
|
logging.error(
|
||||||
exc_info=True)
|
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]}"
|
)
|
||||||
|
error_detail_for_db = (
|
||||||
|
f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}"
|
||||||
|
)
|
||||||
await panel_sync_dal.update_panel_sync_status(
|
await panel_sync_dal.update_panel_sync_status(
|
||||||
session, "failed", error_detail_for_db, users_processed_count,
|
session,
|
||||||
subscriptions_synced_count)
|
"failed",
|
||||||
|
error_detail_for_db,
|
||||||
|
users_processed_count,
|
||||||
|
subscriptions_synced_count,
|
||||||
|
)
|
||||||
|
|
||||||
await bot.send_message(target_chat_id,
|
await bot.send_message(
|
||||||
_("sync_failed", details=error_detail_for_db))
|
target_chat_id, _("sync_failed", details=error_detail_for_db)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("syncstatus"))
|
@router.message(Command("syncstatus"))
|
||||||
async def sync_status_command_handler(message: types.Message, i18n_data: dict,
|
async def sync_status_command_handler(
|
||||||
settings: Settings,
|
message: types.Message, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||||
session: AsyncSession):
|
):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not i18n:
|
if not i18n:
|
||||||
@@ -292,13 +328,16 @@ async def sync_status_command_handler(message: types.Message, i18n_data: dict,
|
|||||||
response_text = ""
|
response_text = ""
|
||||||
if status_record_model:
|
if status_record_model:
|
||||||
last_time_val = status_record_model.last_sync_time
|
last_time_val = status_record_model.last_sync_time
|
||||||
last_time_str = last_time_val.strftime(
|
last_time_str = (
|
||||||
'%Y-%m-%d %H:%M:%S UTC') if last_time_val else "N/A"
|
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_val = status_record_model.details
|
||||||
details_str = (details_val[:200] +
|
details_str = (
|
||||||
"...") if details_val and len(details_val) > 200 else (
|
(details_val[:200] + "...")
|
||||||
details_val or "N/A")
|
if details_val and len(details_val) > 200
|
||||||
|
else (details_val or "N/A")
|
||||||
|
)
|
||||||
|
|
||||||
response_text = (
|
response_text = (
|
||||||
f"<b>{_('admin_stats_last_sync_header')}</b>\n"
|
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_status')}: {status_record_model.status}\n"
|
||||||
f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\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_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:
|
else:
|
||||||
response_text = _("admin_sync_status_never_run")
|
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,
|
async def _get_user_model_by_input(session: AsyncSession,
|
||||||
input_text: str) -> Optional[User]:
|
input_text: str) -> Optional[User]:
|
||||||
|
|
||||||
user_model: Optional[User] = None
|
|
||||||
if input_text.isdigit():
|
if input_text.isdigit():
|
||||||
try:
|
try:
|
||||||
user_model = await user_dal.get_user_by_id(session,
|
return await user_dal.get_user(session, user_id=int(input_text))
|
||||||
int(input_text))
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
return None
|
||||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||||
user_model = await user_dal.get_user_by_username(
|
return await user_dal.get_user(session, username=input_text[1:])
|
||||||
session, input_text[1:])
|
if USERNAME_REGEX.match(input_text):
|
||||||
elif USERNAME_REGEX.match(input_text):
|
return await user_dal.get_user(session, username=input_text)
|
||||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
return None
|
||||||
return user_model
|
|
||||||
|
|
||||||
|
|
||||||
async def ban_user_prompt_handler(callback: types.CallbackQuery,
|
async def ban_user_prompt_handler(callback: types.CallbackQuery,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from db.dal import payment_dal, user_dal
|
|||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
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 bot.middlewares.i18n import JsonI18n
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
|
|
||||||
@@ -105,7 +105,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
subscription_months,
|
subscription_months,
|
||||||
payment_value,
|
payment_value,
|
||||||
payment_db_id,
|
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'):
|
if not activation_details or not activation_details.get('end_date'):
|
||||||
logging.error(
|
logging.error(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from aiogram import Router, F, types, Bot
|
from aiogram import Router, F, types, Bot
|
||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
|
||||||
from typing import Optional, Dict, Any, Union
|
from typing import Optional, Dict, Any, Union
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -8,11 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import payment_dal
|
from db.dal import payment_dal
|
||||||
from bot.keyboards.inline.user_keyboards import (
|
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)
|
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.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
|
||||||
router = Router(name="user_subscription_router")
|
router = Router(name="user_subscription_router")
|
||||||
@@ -98,28 +101,70 @@ async def select_subscription_period_callback_handler(
|
|||||||
return
|
return
|
||||||
|
|
||||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||||
confirmation_text_content = get_text("confirm_subscription_prompt",
|
text_content = get_text("choose_payment_method")
|
||||||
months=months,
|
tribute_url = settings.tribute_payment_links.get(months)
|
||||||
price=f"{price_rub:.2f}",
|
stars_price = settings.stars_subscription_options.get(months)
|
||||||
currency_symbol=currency_symbol_val)
|
reply_markup = get_payment_method_keyboard(
|
||||||
reply_markup = get_confirm_subscription_keyboard(months, price_rub,
|
months,
|
||||||
currency_symbol_val,
|
price_rub,
|
||||||
current_lang, i18n)
|
tribute_url,
|
||||||
|
stars_price,
|
||||||
|
currency_symbol_val,
|
||||||
|
current_lang,
|
||||||
|
i18n,
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await callback.message.edit_text(confirmation_text_content,
|
await callback.message.edit_text(text_content,
|
||||||
reply_markup=reply_markup)
|
reply_markup=reply_markup)
|
||||||
except Exception as e_edit:
|
except Exception as e_edit:
|
||||||
logging.warning(
|
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)
|
reply_markup=reply_markup)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data.startswith("confirm_sub:"))
|
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||||
async def confirm_subscription_callback_handler(
|
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,
|
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||||
yookassa_service: YooKassaService, session: AsyncSession):
|
yookassa_service: YooKassaService, session: AsyncSession):
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
@@ -150,7 +195,7 @@ async def confirm_subscription_callback_handler(
|
|||||||
price_rub = float(price_str)
|
price_rub = float(price_str)
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
logging.error(
|
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)
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -257,97 +302,115 @@ async def reshow_subscription_options_callback(callback: types.CallbackQuery,
|
|||||||
|
|
||||||
|
|
||||||
async def my_subscription_command_handler(
|
async def my_subscription_command_handler(
|
||||||
event: Union[types.Message, types.CallbackQuery], i18n_data: dict,
|
event: Union[types.Message, types.CallbackQuery],
|
||||||
settings: Settings, panel_service: PanelApiService,
|
i18n_data: dict,
|
||||||
subscription_service: SubscriptionService, session: AsyncSession,
|
settings: Settings,
|
||||||
bot: Bot):
|
panel_service: PanelApiService,
|
||||||
target_message_obj = event.message if isinstance(
|
subscription_service: SubscriptionService,
|
||||||
event, types.CallbackQuery) else event
|
session: AsyncSession,
|
||||||
user = event.from_user
|
bot: Bot
|
||||||
|
):
|
||||||
if isinstance(event, types.CallbackQuery):
|
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||||
await event.answer()
|
|
||||||
|
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||||
) if i18n else key
|
|
||||||
|
|
||||||
if not i18n or not target_message_obj:
|
if not i18n or not target:
|
||||||
if isinstance(event, types.Message):
|
if isinstance(event, types.Message):
|
||||||
await event.answer(get_text("error_occurred_try_again"))
|
await event.answer(get_text("error_occurred_try_again"))
|
||||||
return
|
return
|
||||||
|
|
||||||
if not panel_service or not subscription_service:
|
if not panel_service or not subscription_service:
|
||||||
logging.error(
|
await target.answer(get_text("error_service_unavailable"))
|
||||||
"PanelService or SubscriptionService is missing in my_subscription_command_handler."
|
|
||||||
)
|
|
||||||
await target_message_obj.answer(get_text("error_service_unavailable"))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
active_sub_details = await subscription_service.get_active_subscription_details(
|
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||||
session, user.id)
|
|
||||||
|
|
||||||
sub_info_text_content = ""
|
if not active:
|
||||||
if active_sub_details:
|
text = get_text("subscription_not_active")
|
||||||
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
|
|
||||||
|
|
||||||
actual_config_link = active_sub_details.get('config_link') or get_text(
|
buy_button = InlineKeyboardButton(
|
||||||
"config_link_not_available")
|
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')
|
kb = InlineKeyboardMarkup(
|
||||||
traffic_used_bytes = active_sub_details.get('traffic_used_bytes')
|
inline_keyboard=[
|
||||||
|
[buy_button],
|
||||||
|
*back_markup.inline_keyboard
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
traffic_limit_gb_str = get_text("traffic_unlimited")
|
if isinstance(event, types.CallbackQuery):
|
||||||
if traffic_limit_bytes and traffic_limit_bytes > 0:
|
await event.answer()
|
||||||
traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB"
|
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")
|
end_date = active.get("end_date")
|
||||||
if traffic_used_bytes is not None:
|
days_left = (
|
||||||
traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB"
|
(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(
|
if isinstance(event, types.CallbackQuery):
|
||||||
"my_subscription_details",
|
await event.answer()
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
await event.message.edit_text(sub_info_text_content,
|
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
reply_markup=reply_markup_val,
|
except:
|
||||||
parse_mode="HTML",
|
await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
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)
|
|
||||||
else:
|
else:
|
||||||
await target_message_obj.answer(sub_info_text_content,
|
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||||
reply_markup=reply_markup_val,
|
|
||||||
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"))
|
@router.message(Command("connect"))
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ from datetime import datetime
|
|||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
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.keyboards.inline.user_keyboards import (
|
||||||
|
get_trial_confirmation_keyboard,
|
||||||
|
get_main_menu_inline_keyboard,
|
||||||
|
)
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from .start import send_main_menu
|
from .start import send_main_menu
|
||||||
|
|
||||||
@@ -15,13 +18,16 @@ router = Router(name="user_trial_router")
|
|||||||
|
|
||||||
|
|
||||||
async def request_trial_confirmation_handler(
|
async def request_trial_confirmation_handler(
|
||||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
callback: types.CallbackQuery,
|
||||||
subscription_service: SubscriptionService, session: AsyncSession):
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
) if i18n else key
|
|
||||||
|
|
||||||
if not i18n or not callback.message:
|
if not i18n or not callback.message:
|
||||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||||
@@ -29,50 +35,60 @@ async def request_trial_confirmation_handler(
|
|||||||
|
|
||||||
show_trial_btn_in_menu_if_fail = False
|
show_trial_btn_in_menu_if_fail = False
|
||||||
if settings.TRIAL_ENABLED:
|
if settings.TRIAL_ENABLED:
|
||||||
if not await subscription_service.has_had_any_subscription(
|
if not await subscription_service.has_had_any_subscription(session, user_id):
|
||||||
session, user_id):
|
|
||||||
show_trial_btn_in_menu_if_fail = True
|
show_trial_btn_in_menu_if_fail = True
|
||||||
|
|
||||||
if not settings.TRIAL_ENABLED:
|
if not settings.TRIAL_ENABLED:
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_("trial_feature_disabled"),
|
_("trial_feature_disabled"),
|
||||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
|
reply_markup=get_main_menu_inline_keyboard(
|
||||||
settings, False))
|
current_lang, i18n, settings, False
|
||||||
|
),
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
_("trial_already_had_subscription_or_trial"),
|
_("trial_already_had_subscription_or_trial"),
|
||||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
|
reply_markup=get_main_menu_inline_keyboard(
|
||||||
settings, False))
|
current_lang, i18n, settings, False
|
||||||
|
),
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
return
|
return
|
||||||
|
|
||||||
traffic_gb_display = str(
|
traffic_gb_display = (
|
||||||
settings.TRIAL_TRAFFIC_LIMIT_GB
|
str(settings.TRIAL_TRAFFIC_LIMIT_GB)
|
||||||
) if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0 else _(
|
if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0
|
||||||
"traffic_unlimited")
|
else _("traffic_unlimited")
|
||||||
|
)
|
||||||
|
|
||||||
await callback.message.edit_text(
|
await callback.message.edit_text(
|
||||||
text=_("trial_confirm_prompt",
|
text=_(
|
||||||
days=settings.TRIAL_DURATION_DAYS,
|
"trial_confirm_prompt",
|
||||||
traffic_gb=traffic_gb_display),
|
days=settings.TRIAL_DURATION_DAYS,
|
||||||
reply_markup=get_trial_confirmation_keyboard(current_lang, i18n))
|
traffic_gb=traffic_gb_display,
|
||||||
|
),
|
||||||
|
reply_markup=get_trial_confirmation_keyboard(current_lang, i18n),
|
||||||
|
)
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "trial_action:confirm_activate")
|
@router.callback_query(F.data == "trial_action:confirm_activate")
|
||||||
async def confirm_activate_trial_handler(
|
async def confirm_activate_trial_handler(
|
||||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
callback: types.CallbackQuery,
|
||||||
subscription_service: SubscriptionService,
|
settings: Settings,
|
||||||
panel_service: PanelApiService, session: AsyncSession):
|
i18n_data: dict,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession,
|
||||||
|
):
|
||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
|
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||||
) if i18n else key
|
|
||||||
|
|
||||||
if not i18n or not callback.message:
|
if not i18n or not callback.message:
|
||||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||||
@@ -81,26 +97,22 @@ async def confirm_activate_trial_handler(
|
|||||||
if not settings.TRIAL_ENABLED:
|
if not settings.TRIAL_ENABLED:
|
||||||
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
||||||
|
|
||||||
await send_main_menu(callback,
|
await send_main_menu(
|
||||||
settings,
|
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||||
i18n_data,
|
)
|
||||||
subscription_service,
|
|
||||||
session,
|
|
||||||
is_edit=True)
|
|
||||||
return
|
return
|
||||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||||
await callback.answer(_("trial_already_had_subscription_or_trial"),
|
await callback.answer(
|
||||||
show_alert=True)
|
_("trial_already_had_subscription_or_trial"), show_alert=True
|
||||||
await send_main_menu(callback,
|
)
|
||||||
settings,
|
await send_main_menu(
|
||||||
i18n_data,
|
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||||
subscription_service,
|
)
|
||||||
session,
|
|
||||||
is_edit=True)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
activation_result = await subscription_service.activate_trial_subscription(
|
activation_result = await subscription_service.activate_trial_subscription(
|
||||||
session, user_id)
|
session, user_id
|
||||||
|
)
|
||||||
|
|
||||||
final_message_text_in_chat = ""
|
final_message_text_in_chat = ""
|
||||||
show_trial_button_after_action = False
|
show_trial_button_after_action = False
|
||||||
@@ -110,35 +122,43 @@ async def confirm_activate_trial_handler(
|
|||||||
|
|
||||||
end_date_obj = activation_result.get("end_date")
|
end_date_obj = activation_result.get("end_date")
|
||||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||||
"config_link_not_available")
|
"config_link_not_available"
|
||||||
|
)
|
||||||
|
|
||||||
if config_link_for_trial == _(
|
traffic_gb_val = activation_result.get(
|
||||||
"config_link_not_available") and activation_result.get(
|
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||||
"panel_short_uuid"):
|
)
|
||||||
generated_link = await panel_service.get_subscription_link(
|
traffic_display = (
|
||||||
activation_result["panel_short_uuid"])
|
f"{traffic_gb_val} GB"
|
||||||
if generated_link: config_link_for_trial = generated_link
|
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 = _(
|
final_message_text_in_chat = _(
|
||||||
"trial_activated_details_message",
|
"trial_activated_details_message",
|
||||||
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
|
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
|
||||||
end_date=end_date_obj.strftime('%Y-%m-%d') if isinstance(
|
end_date=(
|
||||||
end_date_obj, datetime) else "N/A",
|
end_date_obj.strftime("%Y-%m-%d")
|
||||||
|
if isinstance(end_date_obj, datetime)
|
||||||
|
else "N/A"
|
||||||
|
),
|
||||||
config_link=config_link_for_trial,
|
config_link=config_link_for_trial,
|
||||||
traffic_gb=traffic_display)
|
traffic_gb=traffic_display,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
message_key_from_service = activation_result.get(
|
message_key_from_service = (
|
||||||
"message_key", "trial_activation_failed"
|
activation_result.get("message_key", "trial_activation_failed")
|
||||||
) if activation_result else "trial_activation_failed"
|
if activation_result
|
||||||
|
else "trial_activation_failed"
|
||||||
|
)
|
||||||
final_message_text_in_chat = _(message_key_from_service)
|
final_message_text_in_chat = _(message_key_from_service)
|
||||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
if (
|
||||||
session, user_id):
|
settings.TRIAL_ENABLED
|
||||||
|
and not await subscription_service.has_had_any_subscription(
|
||||||
|
session, user_id
|
||||||
|
)
|
||||||
|
):
|
||||||
show_trial_button_after_action = True
|
show_trial_button_after_action = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -146,31 +166,38 @@ async def confirm_activate_trial_handler(
|
|||||||
final_message_text_in_chat,
|
final_message_text_in_chat,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
reply_markup=get_main_menu_inline_keyboard(
|
reply_markup=get_main_menu_inline_keyboard(
|
||||||
current_lang, i18n, settings, show_trial_button_after_action),
|
current_lang, i18n, settings, show_trial_button_after_action
|
||||||
disable_web_page_preview=True)
|
),
|
||||||
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
except Exception as e_edit:
|
except Exception as e_edit:
|
||||||
logging.warning(
|
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,
|
if (
|
||||||
'chat') and callback.message.chat:
|
callback.message
|
||||||
|
and hasattr(callback.message, "chat")
|
||||||
|
and callback.message.chat
|
||||||
|
):
|
||||||
await callback.message.chat.send_message(
|
await callback.message.chat.send_message(
|
||||||
final_message_text_in_chat,
|
final_message_text_in_chat,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
reply_markup=get_main_menu_inline_keyboard(
|
reply_markup=get_main_menu_inline_keyboard(
|
||||||
current_lang, i18n, settings,
|
current_lang, i18n, settings, show_trial_button_after_action
|
||||||
show_trial_button_after_action),
|
),
|
||||||
disable_web_page_preview=True)
|
disable_web_page_preview=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "main_action:cancel_trial")
|
@router.callback_query(F.data == "main_action:cancel_trial")
|
||||||
async def cancel_trial_activation(callback: types.CallbackQuery,
|
async def cancel_trial_activation(
|
||||||
settings: Settings, i18n_data: dict,
|
callback: types.CallbackQuery,
|
||||||
subscription_service: SubscriptionService,
|
settings: Settings,
|
||||||
session: AsyncSession):
|
i18n_data: dict,
|
||||||
await send_main_menu(callback,
|
subscription_service: SubscriptionService,
|
||||||
settings,
|
session: AsyncSession,
|
||||||
i18n_data,
|
):
|
||||||
subscription_service,
|
await send_main_menu(
|
||||||
session,
|
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||||
is_edit=True)
|
)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
|||||||
callback_data="admin_action:broadcast")
|
callback_data="admin_action:broadcast")
|
||||||
builder.button(text=_(key="admin_create_promo_button"),
|
builder.button(text=_(key="admin_create_promo_button"),
|
||||||
callback_data="admin_action:create_promo")
|
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"),
|
builder.button(text=_(key="admin_view_promos_button"),
|
||||||
callback_data="admin_action:view_promos")
|
callback_data="admin_action:view_promos")
|
||||||
builder.button(text=_(key="admin_ban_user_button"),
|
builder.button(text=_(key="admin_ban_user_button"),
|
||||||
|
|||||||
@@ -108,16 +108,21 @@ def get_subscription_options_keyboard(subscription_options: Dict[
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_confirm_subscription_keyboard(months: int, price: float,
|
def get_payment_method_keyboard(months: int, price: float,
|
||||||
currency_symbol_val: str, lang: str,
|
tribute_url: Optional[str],
|
||||||
i18n_instance) -> InlineKeyboardMarkup:
|
stars_price: Optional[int],
|
||||||
|
currency_symbol_val: str, lang: str,
|
||||||
|
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
confirm_text = _(key="confirm_payment_button",
|
if settings.STARS_ENABLED and stars_price is not None:
|
||||||
price=price,
|
builder.button(text=_("pay_with_stars_button"),
|
||||||
currency_symbol=currency_symbol_val)
|
callback_data=f"pay_stars:{months}:{stars_price}")
|
||||||
builder.button(text=confirm_text,
|
if settings.TRIBUTE_ENABLED and tribute_url:
|
||||||
callback_data=f"confirm_sub:{months}:{price}")
|
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}")
|
||||||
builder.button(text=_(key="cancel_button"),
|
builder.button(text=_(key="cancel_button"),
|
||||||
callback_data="main_action:subscribe")
|
callback_data="main_action:subscribe")
|
||||||
builder.adjust(1)
|
builder.adjust(1)
|
||||||
@@ -159,5 +164,5 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
|
|||||||
return None
|
return None
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
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()
|
return builder.as_markup()
|
||||||
|
|||||||
+129
-83
@@ -26,11 +26,13 @@ from bot.handlers.admin import admin_router_aggregate
|
|||||||
from bot.filters.admin_filter import AdminFilter
|
from bot.filters.admin_filter import AdminFilter
|
||||||
|
|
||||||
from bot.services.notification_service import schedule_subscription_notifications
|
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.panel_api_service import PanelApiService
|
||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.referral_service import ReferralService
|
from bot.services.referral_service import ReferralService
|
||||||
from bot.services.promo_code_service import PromoCodeService
|
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.handlers.user import payment as user_payment_webhook_module
|
from bot.handlers.user import payment as user_payment_webhook_module
|
||||||
|
|
||||||
@@ -41,14 +43,17 @@ class DBSessionMiddleware(BaseMiddleware):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self.async_session_factory = async_session_factory
|
self.async_session_factory = async_session_factory
|
||||||
|
|
||||||
async def __call__(self, handler: Callable[[Update, Dict[str, Any]],
|
async def __call__(
|
||||||
Awaitable[Any]], event: Update,
|
self,
|
||||||
data: Dict[str, Any]) -> Any:
|
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||||
|
event: Update,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
if self.async_session_factory is None:
|
if self.async_session_factory is None:
|
||||||
logging.critical(
|
logging.critical("DBSessionMiddleware: async_session_factory is None!")
|
||||||
"DBSessionMiddleware: async_session_factory is None!")
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"async_session_factory not provided to DBSessionMiddleware")
|
"async_session_factory not provided to DBSessionMiddleware"
|
||||||
|
)
|
||||||
|
|
||||||
async with self.async_session_factory() as session:
|
async with self.async_session_factory() as session:
|
||||||
data["session"] = session
|
data["session"] = session
|
||||||
@@ -60,8 +65,8 @@ class DBSessionMiddleware(BaseMiddleware):
|
|||||||
except Exception:
|
except Exception:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.error(
|
logging.error(
|
||||||
"DBSessionMiddleware: Exception caused rollback.",
|
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
|
||||||
exc_info=True)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@@ -89,30 +94,33 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
|||||||
async_session_factory: sessionmaker = dispatcher["async_session_factory"]
|
async_session_factory: sessionmaker = dispatcher["async_session_factory"]
|
||||||
|
|
||||||
logging.info("STARTUP: on_startup_configured executing...")
|
logging.info("STARTUP: on_startup_configured executing...")
|
||||||
scheduler = AsyncIOScheduler(timezone="UTC")
|
|
||||||
try:
|
|
||||||
|
|
||||||
await schedule_subscription_notifications(bot, settings, i18n_instance,
|
existing_scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
|
||||||
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,
|
if existing_scheduler and existing_scheduler.running:
|
||||||
'TELEGRAM_WEBHOOK_BASE_URL', None)
|
logging.warning("STARTUP: Scheduler already running, skipping initialization.")
|
||||||
if telegram_webhook_url_to_set:
|
else:
|
||||||
if settings.BOT_TOKEN in telegram_webhook_url_to_set:
|
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||||
logging.error(
|
try:
|
||||||
f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). "
|
await schedule_subscription_notifications(
|
||||||
"This is a major security vulnerability. Webhook will NOT be set."
|
bot,
|
||||||
|
settings,
|
||||||
|
i18n_instance,
|
||||||
|
scheduler,
|
||||||
|
panel_service,
|
||||||
|
async_session_factory,
|
||||||
)
|
)
|
||||||
full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED"
|
scheduler.start()
|
||||||
else:
|
dispatcher["scheduler"] = scheduler
|
||||||
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
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)
|
||||||
|
if telegram_webhook_url_to_set:
|
||||||
|
full_telegram_webhook_url = (
|
||||||
|
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||||
|
)
|
||||||
|
|
||||||
logging.info(
|
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'}"
|
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 +136,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
|||||||
set_success = await bot.set_webhook(
|
set_success = await bot.set_webhook(
|
||||||
url=full_telegram_webhook_url,
|
url=full_telegram_webhook_url,
|
||||||
drop_pending_updates=True,
|
drop_pending_updates=True,
|
||||||
allowed_updates=dispatcher.resolve_used_update_types())
|
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||||
|
)
|
||||||
if set_success:
|
if set_success:
|
||||||
logging.info(
|
logging.info(
|
||||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
|
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
|
||||||
@@ -150,7 +159,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
|||||||
except Exception as e_setwebhook:
|
except Exception as e_setwebhook:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
||||||
exc_info=True)
|
exc_info=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logging.error(
|
logging.error(
|
||||||
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
||||||
@@ -173,11 +183,12 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
|||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
logging.info("SHUTDOWN: APScheduler shut down.")
|
logging.info("SHUTDOWN: APScheduler shut down.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"SHUTDOWN: Error shutting down APScheduler: {e}",
|
logging.error(
|
||||||
exc_info=True)
|
f"SHUTDOWN: Error shutting down APScheduler: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
panel_service: Optional[PanelApiService] = dispatcher.get("panel_service")
|
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()
|
await panel_service.close_session()
|
||||||
logging.info("Panel API service session closed on shutdown.")
|
logging.info("Panel API service session closed on shutdown.")
|
||||||
|
|
||||||
@@ -190,6 +201,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
|||||||
logging.warning(f"SHUTDOWN: Failed to close bot session: {e}")
|
logging.warning(f"SHUTDOWN: Failed to close bot session: {e}")
|
||||||
|
|
||||||
from db.database_setup import async_engine as global_async_engine
|
from db.database_setup import async_engine as global_async_engine
|
||||||
|
|
||||||
if global_async_engine:
|
if global_async_engine:
|
||||||
logging.info("SHUTDOWN: Disposing SQLAlchemy engine...")
|
logging.info("SHUTDOWN: Disposing SQLAlchemy engine...")
|
||||||
await global_async_engine.dispose()
|
await global_async_engine.dispose()
|
||||||
@@ -222,22 +234,40 @@ 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}"
|
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",
|
i18n_instance = get_i18n_instance(
|
||||||
default=settings_param.DEFAULT_LANGUAGE)
|
path="locales", default=settings_param.DEFAULT_LANGUAGE
|
||||||
|
)
|
||||||
|
|
||||||
yookassa_service = YooKassaService(
|
yookassa_service = YooKassaService(
|
||||||
shop_id=settings_param.YOOKASSA_SHOP_ID,
|
shop_id=settings_param.YOOKASSA_SHOP_ID,
|
||||||
secret_key=settings_param.YOOKASSA_SECRET_KEY,
|
secret_key=settings_param.YOOKASSA_SECRET_KEY,
|
||||||
configured_return_url=settings_param.YOOKASSA_RETURN_URL,
|
configured_return_url=settings_param.YOOKASSA_RETURN_URL,
|
||||||
bot_username_for_default_return=actual_bot_username,
|
bot_username_for_default_return=actual_bot_username,
|
||||||
settings_obj=settings_param)
|
settings_obj=settings_param,
|
||||||
|
)
|
||||||
panel_service = PanelApiService(settings_param)
|
panel_service = PanelApiService(settings_param)
|
||||||
|
|
||||||
subscription_service = SubscriptionService(settings_param, panel_service)
|
subscription_service = SubscriptionService(
|
||||||
referral_service = ReferralService(settings_param, subscription_service,
|
settings_param, panel_service, bot, i18n_instance
|
||||||
bot, i18n_instance)
|
)
|
||||||
promo_code_service = PromoCodeService(settings_param, subscription_service,
|
referral_service = ReferralService(
|
||||||
bot, i18n_instance)
|
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
|
||||||
|
)
|
||||||
|
tribute_service = TributeService(
|
||||||
|
bot,
|
||||||
|
settings_param,
|
||||||
|
i18n_instance,
|
||||||
|
local_async_session_factory,
|
||||||
|
panel_service,
|
||||||
|
subscription_service,
|
||||||
|
referral_service,
|
||||||
|
)
|
||||||
|
|
||||||
dp["i18n_instance"] = i18n_instance
|
dp["i18n_instance"] = i18n_instance
|
||||||
dp["yookassa_service"] = yookassa_service
|
dp["yookassa_service"] = yookassa_service
|
||||||
@@ -245,15 +275,17 @@ async def run_bot(settings_param: Settings):
|
|||||||
dp["subscription_service"] = subscription_service
|
dp["subscription_service"] = subscription_service
|
||||||
dp["referral_service"] = referral_service
|
dp["referral_service"] = referral_service
|
||||||
dp["promo_code_service"] = promo_code_service
|
dp["promo_code_service"] = promo_code_service
|
||||||
|
dp["stars_service"] = stars_service
|
||||||
|
dp["tribute_service"] = tribute_service
|
||||||
dp["async_session_factory"] = local_async_session_factory
|
dp["async_session_factory"] = local_async_session_factory
|
||||||
|
|
||||||
|
dp.update.outer_middleware(DBSessionMiddleware(local_async_session_factory))
|
||||||
dp.update.outer_middleware(
|
dp.update.outer_middleware(
|
||||||
DBSessionMiddleware(local_async_session_factory))
|
I18nMiddleware(i18n=i18n_instance, settings=settings_param)
|
||||||
|
)
|
||||||
dp.update.outer_middleware(
|
dp.update.outer_middleware(
|
||||||
I18nMiddleware(i18n=i18n_instance, settings=settings_param))
|
BanCheckMiddleware(settings=settings_param, i18n_instance=i18n_instance)
|
||||||
dp.update.outer_middleware(
|
)
|
||||||
BanCheckMiddleware(settings=settings_param,
|
|
||||||
i18n_instance=i18n_instance))
|
|
||||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param))
|
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param))
|
||||||
|
|
||||||
dp.startup.register(on_startup_configured)
|
dp.startup.register(on_startup_configured)
|
||||||
@@ -261,13 +293,12 @@ async def run_bot(settings_param: Settings):
|
|||||||
|
|
||||||
await register_all_routers(dp, settings_param)
|
await register_all_routers(dp, settings_param)
|
||||||
|
|
||||||
tg_webhook_base = getattr(settings_param, 'TELEGRAM_WEBHOOK_BASE_URL',
|
tg_webhook_base = getattr(settings_param, "TELEGRAM_WEBHOOK_BASE_URL", None)
|
||||||
None)
|
yk_webhook_base = getattr(settings_param, "YOOKASSA_WEBHOOK_BASE_URL", None)
|
||||||
yk_webhook_base = getattr(settings_param, 'YOOKASSA_WEBHOOK_BASE_URL',
|
|
||||||
None)
|
|
||||||
|
|
||||||
should_run_aiohttp_server = bool(tg_webhook_base) or (
|
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)
|
telegram_uses_webhook_mode = bool(tg_webhook_base)
|
||||||
run_telegram_polling = not telegram_uses_webhook_mode
|
run_telegram_polling = not telegram_uses_webhook_mode
|
||||||
@@ -288,25 +319,28 @@ async def run_bot(settings_param: Settings):
|
|||||||
|
|
||||||
if should_run_aiohttp_server:
|
if should_run_aiohttp_server:
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app['bot'] = bot
|
app["bot"] = bot
|
||||||
app['dp'] = dp
|
app["dp"] = dp
|
||||||
app['settings'] = settings_param
|
app["settings"] = settings_param
|
||||||
app['i18n'] = i18n_instance
|
app["i18n"] = i18n_instance
|
||||||
app['async_session_factory'] = local_async_session_factory
|
app["async_session_factory"] = local_async_session_factory
|
||||||
|
|
||||||
app['yookassa_service'] = yookassa_service
|
app["yookassa_service"] = yookassa_service
|
||||||
app['subscription_service'] = subscription_service
|
app["subscription_service"] = subscription_service
|
||||||
app['referral_service'] = referral_service
|
app["referral_service"] = referral_service
|
||||||
app['panel_service'] = panel_service
|
app["panel_service"] = panel_service
|
||||||
|
app["stars_service"] = stars_service
|
||||||
|
app["tribute_service"] = tribute_service
|
||||||
|
|
||||||
setup_application(app, dp, bot=bot)
|
setup_application(app, dp, bot=bot)
|
||||||
|
|
||||||
if telegram_uses_webhook_mode:
|
if telegram_uses_webhook_mode:
|
||||||
telegram_webhook_path = f"/{settings_param.BOT_TOKEN}"
|
telegram_webhook_path = f"/{settings_param.BOT_TOKEN}"
|
||||||
if not telegram_webhook_path.startswith('/'):
|
if not telegram_webhook_path.startswith("/"):
|
||||||
telegram_webhook_path = '/' + telegram_webhook_path
|
telegram_webhook_path = "/" + telegram_webhook_path
|
||||||
app.router.add_post(telegram_webhook_path,
|
app.router.add_post(
|
||||||
SimpleRequestHandler(dispatcher=dp, bot=bot))
|
telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot)
|
||||||
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||||
)
|
)
|
||||||
@@ -317,40 +351,52 @@ async def run_bot(settings_param: Settings):
|
|||||||
logging.error(
|
logging.error(
|
||||||
f"YooKassa webhook path is invalid or not configured in settings: {yk_path}. Skipping YooKassa webhook setup."
|
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(
|
logging.error(
|
||||||
f"CRITICAL: YooKassa webhook path '{yk_path}' from settings does not start with '/'. Correct settings.py or .env. Skipping YooKassa webhook."
|
f"CRITICAL: YooKassa webhook path '{yk_path}' from settings does not start with '/'. Correct settings.py or .env. Skipping YooKassa webhook."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
app.router.add_post(
|
app.router.add_post(
|
||||||
yk_path,
|
yk_path, user_payment_webhook_module.yookassa_webhook_route
|
||||||
user_payment_webhook_module.yookassa_webhook_route)
|
)
|
||||||
logging.info(
|
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||||
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}")
|
||||||
|
|
||||||
web_app_runner = web.AppRunner(app)
|
web_app_runner = web.AppRunner(app)
|
||||||
await web_app_runner.setup()
|
await web_app_runner.setup()
|
||||||
site = web.TCPSite(web_app_runner,
|
site = web.TCPSite(
|
||||||
host=settings_param.WEB_SERVER_HOST,
|
web_app_runner,
|
||||||
port=settings_param.WEB_SERVER_PORT)
|
host=settings_param.WEB_SERVER_HOST,
|
||||||
|
port=settings_param.WEB_SERVER_PORT,
|
||||||
|
)
|
||||||
|
|
||||||
async def web_server_task():
|
async def web_server_task():
|
||||||
await site.start()
|
await site.start()
|
||||||
logging.info(
|
logging.info(
|
||||||
f"AIOHTTP server started on http://{settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}"
|
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(
|
main_tasks.append(
|
||||||
asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
|
asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")
|
||||||
|
)
|
||||||
|
|
||||||
if run_telegram_polling:
|
if run_telegram_polling:
|
||||||
logging.info("Starting bot in Telegram Polling mode...")
|
logging.info("Starting bot in Telegram Polling mode...")
|
||||||
main_tasks.append(
|
main_tasks.append(
|
||||||
asyncio.create_task(dp.start_polling(
|
asyncio.create_task(
|
||||||
bot, allowed_updates=dp.resolve_used_update_types()),
|
dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()),
|
||||||
name="TelegramPollingTask"))
|
name="TelegramPollingTask",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if not main_tasks:
|
if not main_tasks:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -366,8 +412,7 @@ async def run_bot(settings_param: Settings):
|
|||||||
try:
|
try:
|
||||||
await asyncio.gather(*main_tasks)
|
await asyncio.gather(*main_tasks)
|
||||||
except (KeyboardInterrupt, SystemExit, asyncio.CancelledError) as e:
|
except (KeyboardInterrupt, SystemExit, asyncio.CancelledError) as e:
|
||||||
logging.info(
|
logging.info(f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
|
||||||
f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
|
|
||||||
finally:
|
finally:
|
||||||
logging.info("Initiating final bot shutdown sequence...")
|
logging.info("Initiating final bot shutdown sequence...")
|
||||||
for task in main_tasks:
|
for task in main_tasks:
|
||||||
@@ -382,7 +427,8 @@ async def run_bot(settings_param: Settings):
|
|||||||
except Exception as e_task_cancel:
|
except Exception as e_task_cancel:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Error during cancellation of task '{task.get_name()}': {e_task_cancel}",
|
f"Error during cancellation of task '{task.get_name()}': {e_task_cancel}",
|
||||||
exc_info=True)
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
if web_app_runner:
|
if web_app_runner:
|
||||||
await web_app_runner.cleanup()
|
await web_app_runner.cleanup()
|
||||||
|
|||||||
@@ -226,6 +226,28 @@ class PanelApiService:
|
|||||||
|
|
||||||
return None
|
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(
|
async def get_users_by_filter(
|
||||||
self,
|
self,
|
||||||
telegram_id: Optional[int] = None,
|
telegram_id: Optional[int] = None,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from aiogram import Bot
|
|||||||
|
|
||||||
from config.settings import Settings
|
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 db.models import PromoCode, User
|
||||||
|
|
||||||
from .subscription_service import SubscriptionService
|
from .subscription_service import SubscriptionService
|
||||||
@@ -40,11 +40,6 @@ class PromoCodeService:
|
|||||||
return False, _("promo_code_already_used_by_user",
|
return False, _("promo_code_already_used_by_user",
|
||||||
code=code_input_upper)
|
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
|
bonus_days = promo_data.bonus_days
|
||||||
|
|
||||||
new_end_date = await self.subscription_service.extend_active_subscription_days(
|
new_end_date = await self.subscription_service.extend_active_subscription_days(
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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",
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
success_msg = _("payment_successful", months=months,
|
||||||
|
end_date=final_end.strftime('%Y-%m-%d'))
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(message.from_user.id, success_msg)
|
||||||
|
except Exception as e_send:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send stars payment success message: {e_send}")
|
||||||
|
|
||||||
@@ -2,6 +2,8 @@ import logging
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional, Dict, Any, List, Tuple
|
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.dal import user_dal, subscription_dal, promo_code_dal, payment_dal
|
||||||
from db.models import User, Subscription
|
from db.models import User, Subscription
|
||||||
@@ -12,26 +14,48 @@ from .panel_api_service import PanelApiService
|
|||||||
|
|
||||||
class SubscriptionService:
|
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.settings = settings
|
||||||
self.panel_service = panel_service
|
self.panel_service = panel_service
|
||||||
|
self.bot = bot
|
||||||
|
self.i18n = i18n
|
||||||
|
|
||||||
async def get_user_language(self, session: AsyncSession,
|
async def get_user_language(self, session: AsyncSession, user_id: int) -> str:
|
||||||
user_id: int) -> str:
|
|
||||||
user_record = await user_dal.get_user_by_id(session, user_id)
|
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,
|
async def has_had_any_subscription(
|
||||||
user_id: int) -> bool:
|
self, session: AsyncSession, user_id: int
|
||||||
|
) -> bool:
|
||||||
|
|
||||||
return await subscription_dal.has_any_subscription_for_user(
|
return await subscription_dal.has_any_subscription_for_user(session, user_id)
|
||||||
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(
|
async def _get_or_create_panel_user_link_details(
|
||||||
self,
|
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
|
||||||
session: AsyncSession,
|
|
||||||
user_id: int,
|
|
||||||
db_user: Optional[User] = None
|
|
||||||
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
|
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
|
||||||
if not db_user:
|
if not db_user:
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
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_user_created_or_linked_now = False
|
||||||
|
|
||||||
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
|
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:
|
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]
|
panel_user_obj_from_api = panel_users_by_tg_id_list[0]
|
||||||
logging.info(
|
logging.info(
|
||||||
@@ -68,7 +93,8 @@ class SubscriptionService:
|
|||||||
f"User {user_id} (local panel_uuid: {current_local_panel_uuid}) not found on panel by TG ID. Fetching by panel_uuid."
|
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(
|
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:
|
if not panel_user_obj_from_api:
|
||||||
logging.warning(
|
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."
|
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."
|
||||||
@@ -82,27 +108,33 @@ class SubscriptionService:
|
|||||||
creation_response = await self.panel_service.create_panel_user(
|
creation_response = await self.panel_service.create_panel_user(
|
||||||
username_on_panel=panel_username_on_panel_standard,
|
username_on_panel=panel_username_on_panel_standard,
|
||||||
telegram_id=user_id,
|
telegram_id=user_id,
|
||||||
|
specific_inbound_uuids=self.settings.parsed_default_panel_user_inbound_uuids,
|
||||||
)
|
)
|
||||||
if creation_response and not creation_response.get(
|
if (
|
||||||
"error") and creation_response.get("response"):
|
creation_response
|
||||||
|
and not creation_response.get("error")
|
||||||
|
and creation_response.get("response")
|
||||||
|
):
|
||||||
panel_user_obj_from_api = creation_response.get("response")
|
panel_user_obj_from_api = creation_response.get("response")
|
||||||
panel_user_created_or_linked_now = True
|
panel_user_created_or_linked_now = True
|
||||||
|
|
||||||
elif creation_response and creation_response.get(
|
elif creation_response and creation_response.get("errorCode") == "A019":
|
||||||
"errorCode") == "A019":
|
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username."
|
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(
|
fetched_by_username_list = (
|
||||||
username=panel_username_on_panel_standard)
|
await self.panel_service.get_users_by_filter(
|
||||||
if fetched_by_username_list and len(
|
username=panel_username_on_panel_standard
|
||||||
fetched_by_username_list) == 1:
|
)
|
||||||
|
)
|
||||||
|
if fetched_by_username_list and len(fetched_by_username_list) == 1:
|
||||||
panel_user_obj_from_api = fetched_by_username_list[0]
|
panel_user_obj_from_api = fetched_by_username_list[0]
|
||||||
|
|
||||||
if not panel_user_obj_from_api:
|
if not panel_user_obj_from_api:
|
||||||
logging.error(
|
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'}"
|
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
|
return None, None, None, False
|
||||||
|
|
||||||
if not panel_user_obj_from_api:
|
if not panel_user_obj_from_api:
|
||||||
@@ -110,33 +142,47 @@ class SubscriptionService:
|
|||||||
f"Could not obtain panel user object for TG user {user_id} after all checks."
|
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_uuid_from_api = panel_user_obj_from_api.get("uuid")
|
||||||
actual_panel_username_from_api = panel_user_obj_from_api.get(
|
actual_panel_username_from_api = panel_user_obj_from_api.get("username")
|
||||||
'username')
|
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
|
||||||
panel_telegram_id_from_api = panel_user_obj_from_api.get('telegramId')
|
|
||||||
|
|
||||||
if not actual_panel_uuid_from_api:
|
if not actual_panel_uuid_from_api:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Panel user object for TG user {user_id} does not contain 'uuid'. Data: {panel_user_obj_from_api}"
|
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
|
needs_local_panel_uuid_update = False
|
||||||
if current_local_panel_uuid is None and actual_panel_uuid_from_api:
|
if current_local_panel_uuid is None and actual_panel_uuid_from_api:
|
||||||
needs_local_panel_uuid_update = True
|
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(
|
logging.warning(
|
||||||
f"Local panel_uuid for user {user_id} ('{current_local_panel_uuid}') "
|
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"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
|
needs_local_panel_uuid_update = True
|
||||||
|
|
||||||
if needs_local_panel_uuid_update:
|
if needs_local_panel_uuid_update:
|
||||||
|
|
||||||
conflicting_user_record = await user_dal.get_user_by_panel_uuid(
|
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:
|
if conflicting_user_record and conflicting_user_record.user_id != user_id:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) "
|
f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) "
|
||||||
@@ -151,14 +197,20 @@ class SubscriptionService:
|
|||||||
"panel_user_uuid": actual_panel_uuid_from_api
|
"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
|
db_user.username is None
|
||||||
or db_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
|
):
|
||||||
|
update_data_for_local_user["username"] = (
|
||||||
|
actual_panel_username_from_api
|
||||||
|
)
|
||||||
|
|
||||||
await user_dal.update_user(session, user_id,
|
await user_dal.update_user(session, user_id, update_data_for_local_user)
|
||||||
update_data_for_local_user)
|
|
||||||
db_user.panel_user_uuid = actual_panel_uuid_from_api
|
db_user.panel_user_uuid = actual_panel_uuid_from_api
|
||||||
if "username" in update_data_for_local_user:
|
if "username" in update_data_for_local_user:
|
||||||
db_user.username = update_data_for_local_user["username"]
|
db_user.username = update_data_for_local_user["username"]
|
||||||
@@ -175,69 +227,79 @@ class SubscriptionService:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
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(
|
logging.info(
|
||||||
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'."
|
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(
|
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(
|
panel_sub_link_id = panel_user_obj_from_api.get(
|
||||||
'subscriptionUuid') or panel_user_obj_from_api.get('shortUuid')
|
"subscriptionUuid"
|
||||||
panel_short_uuid = panel_user_obj_from_api.get('shortUuid')
|
) 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:
|
if not panel_sub_link_id and current_local_panel_uuid:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"No subscriptionUuid or shortUuid found on panel for panel_user_uuid {current_local_panel_uuid} (TG ID: {user_id})."
|
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(
|
async def activate_trial_subscription(
|
||||||
self, session: AsyncSession,
|
self, session: AsyncSession, user_id: int
|
||||||
user_id: int) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0:
|
if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0:
|
||||||
return {
|
return {
|
||||||
"eligible": False,
|
"eligible": False,
|
||||||
"activated": 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)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user:
|
if not db_user:
|
||||||
logging.error(
|
logging.error(f"User {user_id} not found in DB, cannot activate trial.")
|
||||||
f"User {user_id} not found in DB, cannot activate trial.")
|
|
||||||
return {
|
return {
|
||||||
"eligible": False,
|
"eligible": False,
|
||||||
"activated": 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):
|
if await self.has_had_any_subscription(session, user_id):
|
||||||
return {
|
return {
|
||||||
"eligible": False,
|
"eligible": False,
|
||||||
"activated": 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(
|
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = (
|
||||||
session, user_id, db_user)
|
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:
|
if not panel_user_uuid or not panel_sub_link_id:
|
||||||
logging.error(
|
logging.error(f"Failed to get panel link details for trial user {user_id}.")
|
||||||
f"Failed to get panel link details for trial user {user_id}.")
|
|
||||||
return {
|
return {
|
||||||
"eligible": True,
|
"eligible": True,
|
||||||
"activated": False,
|
"activated": False,
|
||||||
"message_key": "trial_activation_failed_panel_link"
|
"message_key": "trial_activation_failed_panel_link",
|
||||||
}
|
}
|
||||||
|
|
||||||
start_date = datetime.now(timezone.utc)
|
start_date = datetime.now(timezone.utc)
|
||||||
end_date = start_date + timedelta(
|
end_date = start_date + timedelta(days=self.settings.TRIAL_DURATION_DAYS)
|
||||||
days=self.settings.TRIAL_DURATION_DAYS)
|
|
||||||
|
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
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 = {
|
trial_sub_data = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -255,34 +317,34 @@ class SubscriptionService:
|
|||||||
except Exception as e_upsert:
|
except Exception as e_upsert:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Failed to upsert trial subscription for user {user_id}: {e_upsert}",
|
f"Failed to upsert trial subscription for user {user_id}: {e_upsert}",
|
||||||
exc_info=True)
|
exc_info=True,
|
||||||
|
)
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
return {
|
return {
|
||||||
"eligible": True,
|
"eligible": True,
|
||||||
"activated": False,
|
"activated": False,
|
||||||
"message_key": "trial_activation_failed_db"
|
"message_key": "trial_activation_failed_db",
|
||||||
}
|
}
|
||||||
|
|
||||||
panel_update_payload: Dict[str, Any] = {
|
panel_update_payload: Dict[str, Any] = {
|
||||||
"uuid":
|
"uuid": panel_user_uuid,
|
||||||
panel_user_uuid,
|
"expireAt": end_date.isoformat(timespec="milliseconds").replace(
|
||||||
"expireAt":
|
"+00:00", "Z"
|
||||||
end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
),
|
||||||
"status":
|
"status": "ACTIVE",
|
||||||
"ACTIVE",
|
"trafficLimitBytes": self.settings.trial_traffic_limit_bytes,
|
||||||
"trafficLimitBytes":
|
"trafficLimitStrategy": self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
||||||
self.settings.trial_traffic_limit_bytes,
|
|
||||||
"trafficLimitStrategy":
|
|
||||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
|
||||||
}
|
}
|
||||||
if self.settings.parsed_default_panel_user_inbound_uuids:
|
if self.settings.parsed_default_panel_user_inbound_uuids:
|
||||||
panel_update_payload[
|
panel_update_payload["activeUserInbounds"] = (
|
||||||
"activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids
|
self.settings.parsed_default_panel_user_inbound_uuids
|
||||||
|
)
|
||||||
elif panel_user_created_now:
|
elif panel_user_created_now:
|
||||||
panel_update_payload["activateAllInbounds"] = True
|
panel_update_payload["activateAllInbounds"] = True
|
||||||
|
|
||||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
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"):
|
if not updated_panel_user or updated_panel_user.get("error"):
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}"
|
f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}"
|
||||||
@@ -291,14 +353,13 @@ class SubscriptionService:
|
|||||||
return {
|
return {
|
||||||
"eligible": True,
|
"eligible": True,
|
||||||
"activated": False,
|
"activated": False,
|
||||||
"message_key": "trial_activation_failed_panel_update"
|
"message_key": "trial_activation_failed_panel_update",
|
||||||
}
|
}
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
final_subscription_url = updated_panel_user.get('subscriptionUrl')
|
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||||
final_panel_short_uuid = updated_panel_user.get(
|
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||||
'shortUuid', panel_short_uuid)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"eligible": True,
|
"eligible": True,
|
||||||
@@ -308,7 +369,7 @@ class SubscriptionService:
|
|||||||
"traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
|
"traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||||
"panel_user_uuid": panel_user_uuid,
|
"panel_user_uuid": panel_user_uuid,
|
||||||
"panel_short_uuid": final_panel_short_uuid,
|
"panel_short_uuid": final_panel_short_uuid,
|
||||||
"subscription_url": final_subscription_url
|
"subscription_url": final_subscription_url,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def activate_subscription(
|
async def activate_subscription(
|
||||||
@@ -318,7 +379,8 @@ class SubscriptionService:
|
|||||||
months: int,
|
months: int,
|
||||||
payment_amount: float,
|
payment_amount: float,
|
||||||
payment_db_id: int,
|
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]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
@@ -328,8 +390,9 @@ class SubscriptionService:
|
|||||||
)
|
)
|
||||||
return None
|
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(
|
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = (
|
||||||
session, user_id, db_user)
|
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:
|
if not panel_user_uuid or not panel_sub_link_id:
|
||||||
logging.error(
|
logging.error(
|
||||||
@@ -338,9 +401,14 @@ class SubscriptionService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
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)
|
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
|
start_date = current_active_sub.end_date
|
||||||
|
|
||||||
duration_days_total = months * 30
|
duration_days_total = months * 30
|
||||||
@@ -348,8 +416,13 @@ class SubscriptionService:
|
|||||||
|
|
||||||
if promo_code_id_from_payment:
|
if promo_code_id_from_payment:
|
||||||
promo_model = await promo_code_dal.get_promo_code_by_id(
|
promo_model = await promo_code_dal.get_promo_code_by_id(
|
||||||
session, promo_code_id_from_payment)
|
session, promo_code_id_from_payment
|
||||||
if promo_model and promo_model.is_active and promo_model.current_activations < promo_model.max_activations:
|
)
|
||||||
|
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
|
applied_promo_bonus_days = promo_model.bonus_days
|
||||||
duration_days_total += applied_promo_bonus_days
|
duration_days_total += applied_promo_bonus_days
|
||||||
|
|
||||||
@@ -357,10 +430,12 @@ class SubscriptionService:
|
|||||||
session,
|
session,
|
||||||
promo_code_id_from_payment,
|
promo_code_id_from_payment,
|
||||||
user_id,
|
user_id,
|
||||||
payment_id=payment_db_id)
|
payment_id=payment_db_id,
|
||||||
|
)
|
||||||
if activation:
|
if activation:
|
||||||
await promo_code_dal.increment_promo_code_usage(
|
await promo_code_dal.increment_promo_code_usage(
|
||||||
session, promo_code_id_from_payment)
|
session, promo_code_id_from_payment
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logging.warning(
|
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}."
|
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 +448,8 @@ class SubscriptionService:
|
|||||||
|
|
||||||
final_end_date = start_date + timedelta(days=duration_days_total)
|
final_end_date = start_date + timedelta(days=duration_days_total)
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
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 = {
|
sub_payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -384,48 +460,48 @@ class SubscriptionService:
|
|||||||
"duration_months": months,
|
"duration_months": months,
|
||||||
"is_active": True,
|
"is_active": True,
|
||||||
"status_from_panel": "ACTIVE",
|
"status_from_panel": "ACTIVE",
|
||||||
"traffic_limit_bytes":
|
"traffic_limit_bytes": self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
"provider": provider,
|
||||||
|
"skip_notifications": provider == "tribute",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||||
session, sub_payload)
|
session, sub_payload
|
||||||
|
)
|
||||||
except Exception as e_upsert_sub:
|
except Exception as e_upsert_sub:
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}",
|
f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}",
|
||||||
exc_info=True)
|
exc_info=True,
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
panel_update_payload = {
|
panel_update_payload = {
|
||||||
"uuid":
|
"uuid": panel_user_uuid,
|
||||||
panel_user_uuid,
|
"expireAt": final_end_date.isoformat(timespec="milliseconds").replace(
|
||||||
"expireAt":
|
"+00:00", "Z"
|
||||||
final_end_date.isoformat(timespec='milliseconds').replace(
|
),
|
||||||
'+00:00', 'Z'),
|
"status": "ACTIVE",
|
||||||
"status":
|
"trafficLimitBytes": self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||||
"ACTIVE",
|
"trafficLimitStrategy": self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
||||||
"trafficLimitBytes":
|
|
||||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
|
||||||
"trafficLimitStrategy":
|
|
||||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY
|
|
||||||
}
|
}
|
||||||
if self.settings.parsed_default_panel_user_inbound_uuids:
|
if self.settings.parsed_default_panel_user_inbound_uuids:
|
||||||
panel_update_payload[
|
panel_update_payload["activeUserInbounds"] = (
|
||||||
"activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids
|
self.settings.parsed_default_panel_user_inbound_uuids
|
||||||
|
)
|
||||||
elif panel_user_created_now:
|
elif panel_user_created_now:
|
||||||
panel_update_payload["activateAllInbounds"] = True
|
panel_update_payload["activateAllInbounds"] = True
|
||||||
|
|
||||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
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"):
|
if not updated_panel_user or updated_panel_user.get("error"):
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}"
|
f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
final_subscription_url = updated_panel_user.get('subscriptionUrl')
|
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||||
final_panel_short_uuid = updated_panel_user.get(
|
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||||
'shortUuid', panel_short_uuid)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"subscription_id": new_or_updated_sub.subscription_id,
|
"subscription_id": new_or_updated_sub.subscription_id,
|
||||||
@@ -434,48 +510,84 @@ class SubscriptionService:
|
|||||||
"panel_user_uuid": panel_user_uuid,
|
"panel_user_uuid": panel_user_uuid,
|
||||||
"panel_short_uuid": final_panel_short_uuid,
|
"panel_short_uuid": final_panel_short_uuid,
|
||||||
"subscription_url": final_subscription_url,
|
"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(
|
async def extend_active_subscription_days(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
bonus_days: int,
|
bonus_days: int,
|
||||||
reason: str = "bonus") -> Optional[datetime]:
|
reason: str = "bonus",
|
||||||
|
) -> Optional[datetime]:
|
||||||
user = await user_dal.get_user_by_id(session, user_id)
|
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(
|
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
|
return None
|
||||||
|
|
||||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
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:
|
if not active_sub or not active_sub.end_date:
|
||||||
logging.info(
|
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.PANEL_USER_DEFAULT_TRAFFIC_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
|
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
||||||
now_utc = datetime.now(timezone.utc)
|
session, active_sub.subscription_id, new_end_date_obj
|
||||||
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)
|
|
||||||
|
|
||||||
if updated_sub_model:
|
if updated_sub_model:
|
||||||
panel_update_success = await self.panel_service.update_user_details_on_panel(
|
panel_update_success = (
|
||||||
user.panel_user_uuid, {
|
await self.panel_service.update_user_details_on_panel(
|
||||||
"expireAt":
|
panel_uuid,
|
||||||
new_end_date_obj.isoformat(
|
{
|
||||||
timespec='milliseconds').replace('+00:00', 'Z')
|
"expireAt": new_end_date_obj.isoformat(
|
||||||
})
|
timespec="milliseconds"
|
||||||
|
).replace("+00:00", "Z")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
if not panel_update_success:
|
if not panel_update_success:
|
||||||
logging.warning(
|
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(
|
logging.info(
|
||||||
@@ -489,8 +601,8 @@ class SubscriptionService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_active_subscription_details(
|
async def get_active_subscription_details(
|
||||||
self, session: AsyncSession,
|
self, session: AsyncSession, user_id: int
|
||||||
user_id: int) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not db_user or not db_user.panel_user_uuid:
|
if not db_user or not db_user.panel_user_uuid:
|
||||||
logging.info(
|
logging.info(
|
||||||
@@ -500,136 +612,131 @@ class SubscriptionService:
|
|||||||
|
|
||||||
panel_user_uuid = db_user.panel_user_uuid
|
panel_user_uuid = db_user.panel_user_uuid
|
||||||
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||||
session, user_id, panel_user_uuid)
|
session, user_id, panel_user_uuid
|
||||||
panel_user_data = await self.panel_service.get_user_by_uuid(
|
)
|
||||||
panel_user_uuid)
|
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
||||||
|
|
||||||
if not panel_user_data:
|
if not panel_user_data:
|
||||||
logging.warning(
|
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(
|
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
||||||
timezone.utc):
|
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||||
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
|
|
||||||
}
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if local_active_sub:
|
if local_active_sub:
|
||||||
update_payload_local = {}
|
update_payload_local = {}
|
||||||
panel_status = panel_user_data.get('status', 'UNKNOWN').upper()
|
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
||||||
panel_expire_at_str = panel_user_data.get('expireAt')
|
panel_expire_at_str = panel_user_data.get("expireAt")
|
||||||
panel_traffic_used = panel_user_data.get('usedTrafficBytes')
|
panel_traffic_used = panel_user_data.get("usedTrafficBytes")
|
||||||
panel_traffic_limit = panel_user_data.get('trafficLimitBytes')
|
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
||||||
panel_sub_uuid_from_panel = panel_user_data.get(
|
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:
|
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:
|
if panel_expire_at_str:
|
||||||
panel_expire_dt = datetime.fromisoformat(
|
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(
|
if local_active_sub.end_date.replace(
|
||||||
microsecond=0) != panel_expire_dt.replace(
|
microsecond=0
|
||||||
microsecond=0):
|
) != panel_expire_dt.replace(microsecond=0):
|
||||||
update_payload_local['end_date'] = panel_expire_dt
|
update_payload_local["end_date"] = panel_expire_dt
|
||||||
update_payload_local['last_notification_sent'] = None
|
update_payload_local["last_notification_sent"] = None
|
||||||
if panel_traffic_used is not None and local_active_sub.traffic_used_bytes != panel_traffic_used:
|
if (
|
||||||
update_payload_local['traffic_used_bytes'] = panel_traffic_used
|
panel_traffic_used is not None
|
||||||
if panel_traffic_limit is not None and local_active_sub.traffic_limit_bytes != panel_traffic_limit:
|
and local_active_sub.traffic_used_bytes != panel_traffic_used
|
||||||
update_payload_local[
|
):
|
||||||
'traffic_limit_bytes'] = panel_traffic_limit
|
update_payload_local["traffic_used_bytes"] = panel_traffic_used
|
||||||
if panel_sub_uuid_from_panel and local_active_sub.panel_subscription_uuid != panel_sub_uuid_from_panel:
|
if (
|
||||||
update_payload_local[
|
panel_traffic_limit is not None
|
||||||
'panel_subscription_uuid'] = panel_sub_uuid_from_panel
|
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 (
|
is_active_based_on_panel = panel_status == "ACTIVE" and (
|
||||||
panel_expire_dt > datetime.now(timezone.utc)
|
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:
|
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:
|
if update_payload_local:
|
||||||
await subscription_dal.update_subscription(
|
await subscription_dal.update_subscription(
|
||||||
session, local_active_sub.subscription_id,
|
session, local_active_sub.subscription_id, update_payload_local
|
||||||
update_payload_local)
|
)
|
||||||
|
|
||||||
panel_end_date = datetime.fromisoformat(
|
panel_end_date = (
|
||||||
panel_user_data['expireAt'].replace(
|
datetime.fromisoformat(panel_user_data["expireAt"].replace("Z", "+00:00"))
|
||||||
"Z", "+00:00")) if panel_user_data.get('expireAt') else None
|
if panel_user_data.get("expireAt")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"end_date":
|
"end_date": panel_end_date,
|
||||||
panel_end_date,
|
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||||
"status_from_panel":
|
"config_link": panel_user_data.get("subscriptionUrl"),
|
||||||
panel_user_data.get('status', 'UNKNOWN').upper(),
|
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
||||||
"config_link":
|
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
||||||
panel_user_data.get('subscriptionUrl')
|
"user_bot_username": db_user.username,
|
||||||
or (await self.panel_service.get_subscription_link(
|
"is_panel_data": True,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_subscriptions_ending_soon(
|
async def get_subscriptions_ending_soon(
|
||||||
self, session: AsyncSession,
|
self, session: AsyncSession, days_threshold: int
|
||||||
days_threshold: int) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
subs_models_with_users = await subscription_dal.get_subscriptions_near_expiration(
|
subs_models_with_users = (
|
||||||
session, days_threshold)
|
await subscription_dal.get_subscriptions_near_expiration(
|
||||||
|
session, days_threshold
|
||||||
|
)
|
||||||
|
)
|
||||||
results = []
|
results = []
|
||||||
for sub_model in subs_models_with_users:
|
for sub_model in subs_models_with_users:
|
||||||
if sub_model.user and sub_model.end_date:
|
if (
|
||||||
days_left = (sub_model.end_date - datetime.now(
|
sub_model.user
|
||||||
timezone.utc)).total_seconds() / (24 * 3600)
|
and sub_model.end_date
|
||||||
results.append({
|
and not sub_model.skip_notifications
|
||||||
'user_id':
|
):
|
||||||
sub_model.user_id,
|
days_left = (
|
||||||
'first_name':
|
sub_model.end_date - datetime.now(timezone.utc)
|
||||||
sub_model.user.first_name or f"User {sub_model.user_id}",
|
).total_seconds() / (24 * 3600)
|
||||||
'language_code':
|
results.append(
|
||||||
sub_model.user.language_code
|
{
|
||||||
or self.settings.DEFAULT_LANGUAGE,
|
"user_id": sub_model.user_id,
|
||||||
'end_date_str':
|
"first_name": sub_model.user.first_name
|
||||||
sub_model.end_date.strftime('%Y-%m-%d'),
|
or f"User {sub_model.user_id}",
|
||||||
'days_left':
|
"language_code": sub_model.user.language_code
|
||||||
max(0, int(round(days_left))),
|
or self.settings.DEFAULT_LANGUAGE,
|
||||||
'subscription_end_date_iso_for_update':
|
"end_date_str": sub_model.end_date.strftime("%Y-%m-%d"),
|
||||||
sub_model.end_date
|
"days_left": max(0, int(round(days_left))),
|
||||||
})
|
"subscription_end_date_iso_for_update": sub_model.end_date,
|
||||||
|
}
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def update_last_notification_sent(self, session: AsyncSession,
|
async def update_last_notification_sent(
|
||||||
user_id: int,
|
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
||||||
subscription_end_date: datetime):
|
):
|
||||||
sub_to_update = await subscription_dal.find_subscription_for_notification_update(
|
sub_to_update = (
|
||||||
session, user_id, subscription_end_date)
|
await subscription_dal.find_subscription_for_notification_update(
|
||||||
|
session, user_id, subscription_end_date
|
||||||
|
)
|
||||||
|
)
|
||||||
if sub_to_update:
|
if sub_to_update:
|
||||||
await subscription_dal.update_subscription_notification_time(
|
await subscription_dal.update_subscription_notification_time(
|
||||||
session, sub_to_update.subscription_id,
|
session, sub_to_update.subscription_id, datetime.now(timezone.utc)
|
||||||
datetime.now(timezone.utc))
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}"
|
f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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 db.dal import payment_dal, user_dal, subscription_dal
|
||||||
|
|
||||||
|
|
||||||
|
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('price')
|
||||||
|
|
||||||
|
if not user_id or price_val is None:
|
||||||
|
return web.Response(status=200, text="ok_missing_fields")
|
||||||
|
|
||||||
|
months_map = {int(v): m for m, v in settings.subscription_options.items()}
|
||||||
|
price_rub = price_val / 100
|
||||||
|
months = months_map.get(int(price_rub))
|
||||||
|
if not months:
|
||||||
|
logging.warning(
|
||||||
|
f"Tribute webhook: price {price_val} not mapped to months")
|
||||||
|
return web.Response(status=200, text="ok_price_unmapped")
|
||||||
|
|
||||||
|
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:
|
||||||
|
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",
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
success_msg = _(
|
||||||
|
"payment_successful", months=months,
|
||||||
|
end_date=final_end.strftime('%Y-%m-%d'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
await bot.send_message(user_id, success_msg)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send Tribute payment success message to user {user_id}: {e}")
|
||||||
|
elif event_name == 'cancelled_subscription':
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
await bot.send_message(user_id, _("subscription_cancelled_notification"))
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(
|
||||||
|
f"Failed to notify user {user_id} about cancellation: {e}")
|
||||||
|
await subscription_dal.set_skip_notifications_for_provider(
|
||||||
|
session, user_id, 'tribute', False)
|
||||||
|
await session.commit()
|
||||||
|
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()
|
waiting_for_broadcast_message = State()
|
||||||
confirming_broadcast = State()
|
confirming_broadcast = State()
|
||||||
waiting_for_promo_details = State()
|
waiting_for_promo_details = State()
|
||||||
|
waiting_for_promo_edit_details = State()
|
||||||
waiting_for_user_id_to_ban = State()
|
waiting_for_user_id_to_ban = State()
|
||||||
waiting_for_user_id_to_unban = State()
|
waiting_for_user_id_to_unban = State()
|
||||||
|
|
||||||
|
|||||||
+73
-12
@@ -36,10 +36,31 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
|
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
|
|
||||||
PRICE_1_MONTH: Optional[int] = Field(default=None)
|
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||||
PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
STARS_ENABLED: bool = Field(default=True)
|
||||||
PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
TRIBUTE_ENABLED: bool = Field(default=True)
|
||||||
PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
|
||||||
|
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)
|
||||||
|
|
||||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7)
|
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7)
|
||||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9)
|
SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9)
|
||||||
@@ -141,21 +162,61 @@ class Settings(BaseSettings):
|
|||||||
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}"
|
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def tribute_webhook_path(self) -> str:
|
||||||
|
return "/webhook/tribute"
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
@property
|
||||||
|
def tribute_full_webhook_url(self) -> Optional[str]:
|
||||||
|
if self.YOOKASSA_WEBHOOK_BASE_URL:
|
||||||
|
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.tribute_webhook_path}"
|
||||||
|
return None
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def subscription_options(self) -> Dict[int, float]:
|
def subscription_options(self) -> Dict[int, float]:
|
||||||
options: Dict[int, float] = {}
|
options: Dict[int, float] = {}
|
||||||
|
|
||||||
if self.PRICE_1_MONTH is not None:
|
if self.MONTH_1_ENABLED and self.RUB_PRICE_1_MONTH is not None:
|
||||||
options[1] = float(self.PRICE_1_MONTH / 100.0)
|
options[1] = float(self.RUB_PRICE_1_MONTH)
|
||||||
if self.PRICE_3_MONTHS is not None:
|
if self.MONTH_3_ENABLED and self.RUB_PRICE_3_MONTHS is not None:
|
||||||
options[3] = float(self.PRICE_3_MONTHS / 100.0)
|
options[3] = float(self.RUB_PRICE_3_MONTHS)
|
||||||
if self.PRICE_6_MONTHS is not None:
|
if self.MONTH_6_ENABLED and self.RUB_PRICE_6_MONTHS is not None:
|
||||||
options[6] = float(self.PRICE_6_MONTHS / 100.0)
|
options[6] = float(self.RUB_PRICE_6_MONTHS)
|
||||||
if self.PRICE_12_MONTHS is not None:
|
if self.MONTH_12_ENABLED and self.RUB_PRICE_12_MONTHS is not None:
|
||||||
options[12] = float(self.PRICE_12_MONTHS / 100.0)
|
options[12] = float(self.RUB_PRICE_12_MONTHS)
|
||||||
return options
|
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
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def referral_bonus_inviter(self) -> Dict[int, int]:
|
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()
|
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,
|
async def get_payment_by_db_id(session: AsyncSession,
|
||||||
payment_db_id: int) -> Optional[Payment]:
|
payment_db_id: int) -> Optional[Payment]:
|
||||||
|
|
||||||
@@ -113,3 +122,23 @@ async def get_recent_payment_logs_with_user(session: AsyncSession,
|
|||||||
Payment.created_at.desc()).limit(limit).offset(offset))
|
Payment.created_at.desc()).limit(limit).offset(offset))
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalars().all()
|
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()
|
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(
|
async def increment_promo_code_usage(
|
||||||
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
|
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
|
||||||
promo = await get_promo_code_by_id(session, promo_code_id)
|
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(
|
async def update_subscription_end_date(
|
||||||
session: AsyncSession, subscription_id: int,
|
session: AsyncSession, subscription_id: int,
|
||||||
new_end_date: datetime) -> Optional[Subscription]:
|
new_end_date: datetime) -> Optional[Subscription]:
|
||||||
@@ -156,7 +171,9 @@ async def get_subscriptions_near_expiration(
|
|||||||
threshold_date = now_utc + timedelta(days=days_threshold)
|
threshold_date = now_utc + timedelta(days=days_threshold)
|
||||||
|
|
||||||
stmt = (select(Subscription).join(Subscription.user).where(
|
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,
|
Subscription.end_date <= threshold_date,
|
||||||
or_(
|
or_(
|
||||||
Subscription.last_notification_sent == None,
|
Subscription.last_notification_sent == None,
|
||||||
@@ -203,3 +220,14 @@ async def find_subscription_for_notification_update(
|
|||||||
<= subscription_end_date_to_match + timedelta(seconds=1)).limit(1)
|
<= subscription_end_date_to_match + timedelta(seconds=1)).limit(1)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
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
|
||||||
|
|||||||
+66
-39
@@ -9,33 +9,47 @@ from datetime import datetime
|
|||||||
from ..models import User, Subscription
|
from ..models import User, Subscription
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_id(session: AsyncSession,
|
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||||
user_id: int) -> Optional[User]:
|
|
||||||
stmt = select(User).where(User.user_id == user_id)
|
stmt = select(User).where(User.user_id == user_id)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_username(session: AsyncSession,
|
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||||||
username: str) -> Optional[User]:
|
clean_username = username.lstrip("@").lower()
|
||||||
clean_username = username.lstrip('@').lower()
|
|
||||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
stmt = select(User).where(func.lower(User.username) == clean_username)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def get_user_by_panel_uuid(session: AsyncSession,
|
async def get_user_by_panel_uuid(
|
||||||
panel_uuid: str) -> Optional[User]:
|
session: AsyncSession, panel_uuid: str
|
||||||
|
) -> Optional[User]:
|
||||||
stmt = select(User).where(User.panel_user_uuid == panel_uuid)
|
stmt = select(User).where(User.panel_user_uuid == panel_uuid)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def create_user(session: AsyncSession, user_data: Dict[str,
|
async def get_user(
|
||||||
Any]) -> 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)
|
new_user = User(**user_data)
|
||||||
session.add(new_user)
|
session.add(new_user)
|
||||||
@@ -47,8 +61,9 @@ async def create_user(session: AsyncSession, user_data: Dict[str,
|
|||||||
return new_user
|
return new_user
|
||||||
|
|
||||||
|
|
||||||
async def update_user(session: AsyncSession, user_id: int,
|
async def update_user(
|
||||||
update_data: Dict[str, Any]) -> Optional[User]:
|
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
||||||
|
) -> Optional[User]:
|
||||||
user = await get_user_by_id(session, user_id)
|
user = await get_user_by_id(session, user_id)
|
||||||
if user:
|
if user:
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
@@ -58,16 +73,17 @@ async def update_user(session: AsyncSession, user_id: int,
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
async def update_user_language(session: AsyncSession, user_id: int,
|
async def update_user_language(
|
||||||
lang_code: str) -> bool:
|
session: AsyncSession, user_id: int, lang_code: str
|
||||||
stmt = update(User).where(User.user_id == user_id).values(
|
) -> bool:
|
||||||
language_code=lang_code)
|
stmt = update(User).where(User.user_id == user_id).values(language_code=lang_code)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.rowcount > 0
|
return result.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
async def set_user_ban_status(session: AsyncSession, user_id: int,
|
async def set_user_ban_status(
|
||||||
is_banned: bool) -> bool:
|
session: AsyncSession, user_id: int, is_banned: bool
|
||||||
|
) -> bool:
|
||||||
user = await get_user_by_id(session, user_id)
|
user = await get_user_by_id(session, user_id)
|
||||||
if user:
|
if user:
|
||||||
user.is_banned = is_banned
|
user.is_banned = is_banned
|
||||||
@@ -77,23 +93,27 @@ async def set_user_ban_status(session: AsyncSession, user_id: int,
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def get_banned_users_paginated(session: AsyncSession, limit: int,
|
async def get_banned_users_paginated(
|
||||||
offset: int) -> Tuple[List[User], int]:
|
session: AsyncSession, limit: int, offset: int
|
||||||
stmt_users = select(User).where(User.is_banned == True).order_by(
|
) -> Tuple[List[User], int]:
|
||||||
User.registration_date.desc()).limit(limit).offset(offset)
|
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)
|
result_users = await session.execute(stmt_users)
|
||||||
users_list = result_users.scalars().all()
|
users_list = result_users.scalars().all()
|
||||||
|
|
||||||
stmt_count = select(
|
stmt_count = select(func.count()).select_from(User).where(User.is_banned == True)
|
||||||
func.count()).select_from(User).where(User.is_banned == True)
|
|
||||||
result_count = await session.execute(stmt_count)
|
result_count = await session.execute(stmt_count)
|
||||||
total_banned = result_count.scalar_one()
|
total_banned = result_count.scalar_one()
|
||||||
|
|
||||||
return users_list, total_banned
|
return users_list, total_banned
|
||||||
|
|
||||||
|
|
||||||
async def get_all_active_user_ids_for_broadcast(
|
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
||||||
session: AsyncSession) -> List[int]:
|
|
||||||
stmt = select(User.user_id).where(User.is_banned == False)
|
stmt = select(User.user_id).where(User.is_banned == False)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalars().all()
|
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]:
|
async def get_user_count_stats_dal(session: AsyncSession) -> Dict[str, int]:
|
||||||
total_users_stmt = select(func.count(User.user_id)).select_from(User)
|
total_users_stmt = select(func.count(User.user_id)).select_from(User)
|
||||||
banned_users_stmt = select(func.count(
|
banned_users_stmt = (
|
||||||
User.user_id)).select_from(User).where(User.is_banned == True)
|
select(func.count(User.user_id)).select_from(User).where(User.is_banned == True)
|
||||||
|
)
|
||||||
|
|
||||||
active_subs_stmt = (select(func.count(
|
active_subs_stmt = (
|
||||||
func.distinct(Subscription.user_id))).join(
|
select(func.count(func.distinct(Subscription.user_id)))
|
||||||
User, Subscription.user_id == User.user_id).where(
|
.join(User, Subscription.user_id == User.user_id)
|
||||||
Subscription.is_active == True).where(
|
.where(Subscription.is_active == True)
|
||||||
Subscription.end_date > datetime.now()))
|
.where(Subscription.end_date > datetime.now())
|
||||||
|
)
|
||||||
|
|
||||||
total_users = (await
|
total_users = (await session.execute(total_users_stmt)).scalar_one_or_none() or 0
|
||||||
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
|
||||||
banned_users = (
|
|
||||||
await session.execute(banned_users_stmt)).scalar_one_or_none() or 0
|
|
||||||
active_subs_users = (
|
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 {
|
return {
|
||||||
"total_users": total_users,
|
"total_users": total_users,
|
||||||
"banned_users": banned_users,
|
"banned_users": banned_users,
|
||||||
"users_with_active_subscriptions": active_subs_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_limit_bytes = Column(BigInteger, nullable=True)
|
||||||
traffic_used_bytes = Column(BigInteger, nullable=True)
|
traffic_used_bytes = Column(BigInteger, nullable=True)
|
||||||
last_notification_sent = Column(DateTime(timezone=True), 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")
|
user = relationship("User", back_populates="subscriptions")
|
||||||
|
|
||||||
@@ -89,6 +91,8 @@ class Payment(Base):
|
|||||||
unique=True,
|
unique=True,
|
||||||
index=True,
|
index=True,
|
||||||
nullable=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)
|
idempotence_key = Column(String, unique=True, nullable=True)
|
||||||
amount = Column(Float, nullable=False)
|
amount = Column(Float, nullable=False)
|
||||||
currency = Column(String, nullable=False)
|
currency = Column(String, nullable=False)
|
||||||
|
|||||||
+11
-10
@@ -6,26 +6,27 @@ services:
|
|||||||
hostname: remnawave-tg-shop
|
hostname: remnawave-tg-shop
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
# networks:
|
networks:
|
||||||
# - remnawave-network
|
- remnawave-network
|
||||||
# volumes:
|
volumes:
|
||||||
# - ./locales:/app/locales
|
- ./locales:/app/locales
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
postgres:
|
remnawave-tg-shop-db:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
container_name: remnawave-tg-shop-db
|
container_name: remnawave-tg-shop-db
|
||||||
|
hostname: remnawave-tg-shop-db
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
|
||||||
# networks:
|
networks:
|
||||||
# - remnawave-network
|
- remnawave-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# networks:
|
networks:
|
||||||
# remnawave-network:
|
remnawave-network:
|
||||||
# external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
remnawave-tg-shop-db-data:
|
remnawave-tg-shop-db-data:
|
||||||
|
|||||||
+15
-3
@@ -22,9 +22,11 @@
|
|||||||
"select_subscription_period": "Select subscription period:",
|
"select_subscription_period": "Select subscription period:",
|
||||||
"no_subscription_options_available": "No subscription options available at the moment.",
|
"no_subscription_options_available": "No subscription options available at the moment.",
|
||||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
"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",
|
"pay_button": "💳 Pay",
|
||||||
"confirm_payment_button": "✅ Yes ({price} {currency_symbol})",
|
"pay_with_yookassa_button": "💳 YooKassa",
|
||||||
|
"pay_with_tribute_button": "❤️ Tribute",
|
||||||
|
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||||
"cancel_button": "❌ Cancel",
|
"cancel_button": "❌ Cancel",
|
||||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||||
"payment_service_unavailable": "Payment service temporarily unavailable. Please try again later.",
|
"payment_service_unavailable": "Payment service temporarily unavailable. Please try again later.",
|
||||||
@@ -88,6 +90,7 @@
|
|||||||
"admin_stats_button": "📊 Statistics",
|
"admin_stats_button": "📊 Statistics",
|
||||||
"admin_broadcast_button": "📢 Broadcast",
|
"admin_broadcast_button": "📢 Broadcast",
|
||||||
"admin_create_promo_button": "🎁 Create Promo",
|
"admin_create_promo_button": "🎁 Create Promo",
|
||||||
|
"admin_manage_promos_button": "🛠 Manage Promos",
|
||||||
"admin_view_promos_button": "👀 Promo List",
|
"admin_view_promos_button": "👀 Promo List",
|
||||||
"admin_ban_user_button": "🚫 Ban User",
|
"admin_ban_user_button": "🚫 Ban User",
|
||||||
"admin_unban_user_button": "✅ Unban User",
|
"admin_unban_user_button": "✅ Unban User",
|
||||||
@@ -139,6 +142,13 @@
|
|||||||
"admin_promo_list_item": "<code>{code}</code>: +{bonus}d, {current}/{max} used, valid until {valid_until}",
|
"admin_promo_list_item": "<code>{code}</code>: +{bonus}d, {current}/{max} used, valid until {valid_until}",
|
||||||
"admin_promo_valid_indefinitely": "indefinite",
|
"admin_promo_valid_indefinitely": "indefinite",
|
||||||
"admin_promo_valid_until_display": "until {date}",
|
"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_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.",
|
"admin_user_not_found_in_bot_db": "User <code>{user_id}</code> not found in bot database.",
|
||||||
@@ -166,7 +176,7 @@
|
|||||||
"user_card_sub_na": "n/a",
|
"user_card_sub_na": "n/a",
|
||||||
"user_card_ban_button": "🚫 Ban",
|
"user_card_ban_button": "🚫 Ban",
|
||||||
"user_card_unban_button": "✅ Unban",
|
"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}",
|
"admin_confirm_action_title": "Confirm: {action_text}",
|
||||||
"ban_verb_l": "ban",
|
"ban_verb_l": "ban",
|
||||||
"unban_verb_l": "unban",
|
"unban_verb_l": "unban",
|
||||||
@@ -196,11 +206,13 @@
|
|||||||
"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}",
|
"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",
|
"no_errors_placeholder": "none",
|
||||||
"admin_sync_initiated_from_panel": "Sync initiated...",
|
"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_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.",
|
"error_displaying_statistics": "Error displaying statistics.",
|
||||||
"stub_page_display": "Page",
|
"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_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_cancelled_notification": "Your recurring subscription was cancelled. You will keep access until the paid period ends.",
|
||||||
|
|
||||||
"error_unknown": "An unknown error occurred."
|
"error_unknown": "An unknown error occurred."
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-4
@@ -22,9 +22,11 @@
|
|||||||
"select_subscription_period": "Выберите срок подписки:",
|
"select_subscription_period": "Выберите срок подписки:",
|
||||||
"no_subscription_options_available": "В данный момент нет доступных вариантов подписки.",
|
"no_subscription_options_available": "В данный момент нет доступных вариантов подписки.",
|
||||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||||
"confirm_subscription_prompt": "Подтвердите покупку подписки:\nСрок: {months} мес.\nЦена: {price} {currency_symbol}",
|
"choose_payment_method": "Выберите способ оплаты:",
|
||||||
"pay_button": "💳 Оплатить",
|
"pay_button": "💳 Оплатить",
|
||||||
"confirm_payment_button": "✅ Да ({price} {currency_symbol})",
|
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||||
|
"pay_with_tribute_button": "❤️ Tribute",
|
||||||
|
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||||
"cancel_button": "❌ Отмена",
|
"cancel_button": "❌ Отмена",
|
||||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||||
"payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.",
|
"payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.",
|
||||||
@@ -88,7 +90,8 @@
|
|||||||
"admin_stats_button": "📊 Статистика",
|
"admin_stats_button": "📊 Статистика",
|
||||||
"admin_broadcast_button": "📢 Рассылка",
|
"admin_broadcast_button": "📢 Рассылка",
|
||||||
"admin_create_promo_button": "🎁 Создать промо",
|
"admin_create_promo_button": "🎁 Создать промо",
|
||||||
"admin_view_promos_button": "👀 Спиок промо",
|
"admin_manage_promos_button": "🛠 Управление промо",
|
||||||
|
"admin_view_promos_button": "👀 Список промо",
|
||||||
"admin_ban_user_button": "🚫 Забанить",
|
"admin_ban_user_button": "🚫 Забанить",
|
||||||
"admin_unban_user_button": "✅ Разбанить",
|
"admin_unban_user_button": "✅ Разбанить",
|
||||||
"admin_view_banned_users_button": "📜 Бан-лист",
|
"admin_view_banned_users_button": "📜 Бан-лист",
|
||||||
@@ -139,6 +142,13 @@
|
|||||||
"admin_promo_list_item": "<code>{code}</code>: +{bonus}дн, {current}/{max} акт., до {valid_until}",
|
"admin_promo_list_item": "<code>{code}</code>: +{bonus}дн, {current}/{max} акт., до {valid_until}",
|
||||||
"admin_promo_valid_indefinitely": "бессрочно",
|
"admin_promo_valid_indefinitely": "бессрочно",
|
||||||
"admin_promo_valid_until_display": "до {date}",
|
"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_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
||||||
"admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",
|
"admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",
|
||||||
@@ -166,7 +176,7 @@
|
|||||||
"user_card_sub_na": "н/д",
|
"user_card_sub_na": "н/д",
|
||||||
"user_card_ban_button": "🚫 Заблокировать",
|
"user_card_ban_button": "🚫 Заблокировать",
|
||||||
"user_card_unban_button": "✅ Разблокировать",
|
"user_card_unban_button": "✅ Разблокировать",
|
||||||
"user_card_back_to_banned_list": "⬅️ К списку забаненных",
|
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||||
"admin_confirm_action_title": "Подтверждение: {action_text}",
|
"admin_confirm_action_title": "Подтверждение: {action_text}",
|
||||||
"ban_verb_l": "блокировка",
|
"ban_verb_l": "блокировка",
|
||||||
"unban_verb_l": "разблокировка",
|
"unban_verb_l": "разблокировка",
|
||||||
@@ -196,11 +206,13 @@
|
|||||||
"sync_completed_with_errors_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.\nОшибок: {errors_count}.\n\nПервые ошибки:\n{error_details_preview}",
|
"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": "нет",
|
"no_errors_placeholder": "нет",
|
||||||
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
|
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
|
||||||
|
"admin_panel_user_creation_failed": "❌ Не удалось создать пользователя на панели для TG ID {user_id}. Панель недоступна?",
|
||||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||||
"stub_page_display": "Страница",
|
"stub_page_display": "Страница",
|
||||||
|
|
||||||
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
|
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
|
||||||
|
"subscription_cancelled_notification": "Ваша подписка отменена. Доступ сохранится до конца оплаченного периода.",
|
||||||
|
|
||||||
"error_unknown": "Произошла неизвестная ошибка."
|
"error_unknown": "Произошла неизвестная ошибка."
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
aiogram==3.20.0
|
aiogram==3.20.0
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
aiohttp==3.9.5
|
aiohttp==3.10.11
|
||||||
pydantic==2.7.1
|
pydantic==2.7.1
|
||||||
yookassa==3.5.0
|
yookassa==3.5.0
|
||||||
pycountry==23.12.11
|
pycountry==23.12.11
|
||||||
|
|||||||
Reference in New Issue
Block a user