Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,7 +93,10 @@ 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://localhost: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.
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
+39
-20
@@ -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
|
||||||
|
|
||||||
@@ -89,30 +91,29 @@ 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)
|
if existing_scheduler and existing_scheduler.running:
|
||||||
scheduler.start()
|
logging.warning(
|
||||||
dispatcher["scheduler"] = scheduler
|
"STARTUP: Scheduler already running, skipping initialization.")
|
||||||
logging.info("STARTUP: APScheduler started.")
|
else:
|
||||||
except Exception as e:
|
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||||
logging.error(f"STARTUP: Failed to start APScheduler: {e}",
|
try:
|
||||||
exc_info=True)
|
await schedule_subscription_notifications(
|
||||||
|
bot, settings, i18n_instance, scheduler, panel_service,
|
||||||
|
async_session_factory)
|
||||||
|
scheduler.start()
|
||||||
|
dispatcher["scheduler"] = scheduler
|
||||||
|
logging.info("STARTUP: APScheduler started.")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"STARTUP: Failed to start APScheduler: {e}", exc_info=True)
|
||||||
|
|
||||||
telegram_webhook_url_to_set = getattr(settings,
|
telegram_webhook_url_to_set = getattr(settings,
|
||||||
'TELEGRAM_WEBHOOK_BASE_URL', None)
|
'TELEGRAM_WEBHOOK_BASE_URL', None)
|
||||||
if telegram_webhook_url_to_set:
|
if telegram_webhook_url_to_set:
|
||||||
if settings.BOT_TOKEN in telegram_webhook_url_to_set:
|
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||||
logging.error(
|
|
||||||
f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). "
|
|
||||||
"This is a major security vulnerability. Webhook will NOT be set."
|
|
||||||
)
|
|
||||||
full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED"
|
|
||||||
else:
|
|
||||||
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
|
||||||
|
|
||||||
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'}"
|
||||||
@@ -238,6 +239,12 @@ async def run_bot(settings_param: Settings):
|
|||||||
bot, i18n_instance)
|
bot, i18n_instance)
|
||||||
promo_code_service = PromoCodeService(settings_param, subscription_service,
|
promo_code_service = PromoCodeService(settings_param, subscription_service,
|
||||||
bot, i18n_instance)
|
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,6 +252,8 @@ 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(
|
dp.update.outer_middleware(
|
||||||
@@ -298,6 +307,8 @@ async def run_bot(settings_param: Settings):
|
|||||||
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)
|
||||||
|
|
||||||
@@ -328,6 +339,14 @@ async def run_bot(settings_param: Settings):
|
|||||||
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(web_app_runner,
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
@@ -318,7 +318,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)
|
||||||
@@ -386,6 +387,8 @@ class SubscriptionService:
|
|||||||
"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(
|
||||||
@@ -601,7 +604,7 @@ class SubscriptionService:
|
|||||||
session, days_threshold)
|
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 sub_model.user and sub_model.end_date and not sub_model.skip_notifications:
|
||||||
days_left = (sub_model.end_date - datetime.now(
|
days_left = (sub_model.end_date - datetime.now(
|
||||||
timezone.utc)).total_seconds() / (24 * 3600)
|
timezone.utc)).total_seconds() / (24 * 3600)
|
||||||
results.append({
|
results.append({
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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':
|
||||||
|
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': str(data.get('subscription_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)
|
||||||
+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]:
|
||||||
|
|||||||
@@ -113,3 +113,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
|
||||||
|
|||||||
@@ -156,7 +156,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 +205,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+6
-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.",
|
||||||
@@ -166,7 +168,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",
|
||||||
@@ -201,6 +203,7 @@
|
|||||||
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-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,7 @@
|
|||||||
"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_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": "📜 Бан-лист",
|
||||||
@@ -166,7 +168,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": "разблокировка",
|
||||||
@@ -201,6 +203,7 @@
|
|||||||
"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