Compare commits

..
39 Commits
Author SHA1 Message Date
Machka PaslaandGitHub fd2d0dd570 Merge pull request #24 from machka-pasla/codex/fix-typeerror-with-unsupported-operand-types-for
Fix admin state filter
2025-06-27 20:58:44 +03:00
Machka Pasla abaaf65da9 Fix state filter in promo codes handler 2025-06-27 20:58:00 +03:00
Machka PaslaandGitHub 699964adc0 Merge pull request #23 from machka-pasla/dev
Dev
2025-06-27 20:44:01 +03:00
Machka PaslaandGitHub 74b5587c77 Merge pull request #22 from machka-pasla/codex/refactor-code-for-readability-and-consistency
Add promo management and unify user lookup
2025-06-27 20:43:25 +03:00
Machka Pasla d196801e2e Add promo management and unified user lookup 2025-06-27 20:38:03 +03:00
Machka PaslaandGitHub c12958e9a2 Merge pull request #21 from machka-pasla/codex/fix-activeuserinbounds-filter-in-api-request
Fix default inbounds not applied on user creation
2025-06-27 14:29:46 +03:00
Machka Pasla f4bb61dd1b Use default inbound UUIDs when creating panel user 2025-06-27 14:29:16 +03:00
Machka PaslaandGitHub 24a7e6cc6a Merge pull request #20 from machka-pasla/codex/fix-payment-error-with-tribute
Fix Tribute duplicate payment error
2025-06-27 00:17:27 +03:00
Machka Pasla c6f5e06eba Handle duplicate Tribute payments 2025-06-27 00:17:09 +03:00
Machka PaslaandGitHub ca35567012 Fix typo 2025-06-26 01:36:43 +03:00
Machka PaslaandGitHub cdefe27f89 Merge pull request #18 from machka-pasla/codex/implement-user-creation-and-subscription-updates
Improve panel user handling and promo code activation
2025-06-26 01:28:36 +03:00
Machka Pasla 7200d6e342 Handle missing panel users and allow promo codes 2025-06-26 01:26:55 +03:00
Machka PaslaandGitHub fd4b9b828b Merge pull request #16 from machka-pasla/codex/refactor-panel-unreachable-handling
Fix panel unreachable handling
2025-06-26 00:59:03 +03:00
Machka Pasla b2a0ea5bd2 Remove panel unreachable placeholders and enhance sync 2025-06-26 00:57:48 +03:00
Machka PaslaandGitHub b97774c5b5 Update README.md 2025-06-25 22:15:23 +03:00
Machka PaslaandGitHub d5b4f18dbc Merge pull request #14 from machka-pasla/dev
Move tribute webhook route to service
2025-06-24 19:18:34 +03:00
Machka PaslaandGitHub a94ee8b554 Merge pull request #13 from machka-pasla/codex/locate-usage-of-handlers/webhooks-folder
Move tribute webhook route to service
2025-06-24 19:15:09 +03:00
Machka Pasla 7a90e24427 Move tribute webhook route to service 2025-06-24 19:12:17 +03:00
Machka PaslaandGitHub 891f9354ac Merge pull request #12 from machka-pasla/dev
Tribute and TG stars payment
2025-06-24 10:34:36 +03:00
Machka PaslaandGitHub 89709d6626 Merge pull request #10 from machka-pasla/3eerd1-codex/update-payment-methods-variables-and-readme.md
Add payment method toggles and new pricing variables
2025-06-24 01:36:31 +03:00
Machka Pasla eea6afbcc7 Refactor payment logic into dedicated services 2025-06-24 01:35:20 +03:00
Machka PaslaandGitHub 99539542ca Merge pull request #9 from machka-pasla/codex/update-payment-methods-variables-and-readme.md
Add payment method toggles and new pricing variables
2025-06-24 01:18:49 +03:00
Machka Pasla 6e8f810cbd feat: add payment method toggles and new pricing vars 2025-06-24 01:18:30 +03:00
Machka PaslaandGitHub c7294ddaba Merge pull request #8 from machka-pasla/codex/fix-unsuccessful-payment-notification-and-nonetype-error
Fix payment success notifications
2025-06-24 00:40:14 +03:00
Machka Pasla cb82637b8e Fix payment notifications 2025-06-23 23:59:17 +03:00
Machka Pasla aa20ac9a18 Notify users after Tribute payment 2025-06-23 23:42:24 +03:00
Machka PaslaandGitHub 53464ab25c Merge pull request #3 from machka-pasla/codex/locate-and-fix-apscheduler-duplicate-notifications
Fix duplicate scheduler instances
2025-06-23 20:13:03 +03:00
Machka Pasla 1e0e883c6e Prevent duplicate scheduler startup 2025-06-23 20:12:44 +03:00
machka d14afc5720 locales update 2025-05-23 10:28:08 +00:00
machka 24706388c3 user_keyboards.py update 2025-05-23 10:16:53 +00:00
machka df9e36928c Broadcast HTML fix and subscription keyboards improvements 2025-05-21 19:30:25 +00:00
machka c245ccb55b requirements.txt update 2025-05-21 15:45:41 +00:00
machka ba348eb639 Logger and docker-compose.yml update 2025-05-21 12:23:11 +00:00
machka f6ce39f7c1 Dockerfile update №2 2025-05-21 11:14:13 +00:00
machka 4732a97611 Dockerfile update 2025-05-21 11:04:05 +00:00
machka 0fe29c19b1 ru.json update 2025-05-21 07:59:38 +00:00
machka 7da42c9e25 README update 2 2025-05-21 07:55:38 +00:00
machka e490769044 README update 2025-05-21 07:53:23 +00:00
machka dc8ba3ce06 Exclude readme.md from docker-publish.yml 2025-05-20 19:23:26 +00:00
32 changed files with 1844 additions and 819 deletions
+27 -5
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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"]
+56 -41
View File
@@ -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
View File
@@ -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()
+5
View File
@@ -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)
+148 -2
View File
@@ -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
View File
@@ -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")
+7 -11
View File
@@ -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,
+3 -2
View File
@@ -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(
+152 -89
View File
@@ -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"))
+104 -77
View File
@@ -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) )
+2
View File
@@ -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"),
+14 -9
View File
@@ -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
View File
@@ -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()
+22
View File
@@ -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,
+1 -6
View File
@@ -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(
+135
View File
@@ -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}")
+339 -232
View File
@@ -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}"
) )
+166
View File
@@ -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)
+1
View File
@@ -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
View File
@@ -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]:
+29
View File
@@ -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
+21
View File
@@ -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)
+29 -1
View File
@@ -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
View File
@@ -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()
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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