Compare commits

..
23 Commits
Author SHA1 Message Date
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
20 changed files with 846 additions and 288 deletions
+27 -5
View File
@@ -31,11 +31,33 @@ YOOKASSA_PAYMENT_SUBJECT=payment
# If unset, bot will use polling while YooKassa uses webhooks.
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
# Subscription Prices (integer values)
PRICE_1_MONTH=150
PRICE_3_MONTHS=300
PRICE_6_MONTHS=500
PRICE_12_MONTHS=900
# Payment Methods
YOOKASSA_ENABLED=True
STARS_ENABLED=True
TRIBUTE_ENABLED=True
# 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_NOTIFICATION_DAYS=7
+11 -2
View File
@@ -1,11 +1,20 @@
FROM python:3.11-slim
FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN --mount=type=cache,target=/root/.cache/pip \
pip install --no-cache-dir -r requirements.txt
FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY . .
RUN rm -rf /root/.cache
CMD ["python", "main.py"]
+55 -40
View File
@@ -47,7 +47,7 @@ This Telegram bot is designed to automate the sale and management of subscriptio
* **Python 3.11**
* **Aiogram 3.x:** Asynchronous Telegram Bot Framework
* **aiohttp:** For running the webhook server
* **aiosqlite:** Asynchronous SQLite database interaction
* **sqlalchemy:** Asynchronous PostgreSQL database interaction
* **YooKassa SDK:** For payment processing
* **APScheduler:** For scheduled tasks (e.g., notifications)
* **Pydantic:** For settings management (loading from `.env`)
@@ -93,7 +93,10 @@ This Telegram bot is designed to automate the sale and management of subscriptio
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
* `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates.
* `PRICE_X_MONTH`: Prices for different subscription durations.
* **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_URL`: Full URL to your Remnawave panel's API (e.g., `http://localhost:3000/api` or `https://panel.yourdomain.com/api`).
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
@@ -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.
3. **Locales:**
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. The `bot_database.sqlite3` and `locales` directory will be mounted as volumes in Docker. `locales` mounting is optional.
* Translation files are in the `locales/` directory (`en.json`, `ru.json`). Ensure they are present and correctly formatted. `locales` mounting is optional.
4. **Build and Run with Docker Compose:**
4. **Run with Docker Compose:**
```bash
docker compose up --build -d
docker compose up -d
```
This command will build the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `vpn-shop` service in detached mode.
This command will pull the Docker image (if it doesn't exist or if `Dockerfile` changed) and start the `remnawave-tg-shop` service in detached mode.
5. **Webhook Setup (Important if using webhooks):**
* **Reverse Proxy (Nginx, Caddy, etc.):** You need a reverse proxy to handle incoming HTTPS traffic, manage SSL certificates (e.g., from Let's Encrypt), and forward requests to your bot's container.
* Forward requests for `https://{YOOKASSA_WEBHOOK_BASE_URL_domain}/webhook/yookassa` to `http://vpn-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `vpn-shop` is the service name in `docker-compose.yml`).
* If using Telegram webhooks, forward requests for `https://{TELEGRAM_WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://vpn-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
* 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://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`.
6. **Database:**
* A SQLite database file (`bot_database.sqlite3`) will be created in your project root (or wherever you map the volume). The schema is initialized automatically on the first run if the file doesn't exist.
* A PostgreSQL database will be created in the docker container. The schema is initialized automatically on the first run if the database doesn't exist.
7. **Viewing Logs:**
```bash
docker compose logs -f vpn-shop
docker compose logs -f remnawave-tg-shop
```
## 🐳 Docker Setup
@@ -154,31 +157,39 @@ CMD ["python", "main.py"]
```
### `docker-compose.yml`
YAML
```
services:
vpn-shop:
build: .
container_name: vpn-shop
hostname: vpn-shop
networks:
- remnawave-network # Ensure this external network exists or define it
volumes:
- ./bot_database.sqlite3:/app/bot_database.sqlite3
# - ./locales:/app/locales
remnawave-tg-shop:
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
# build: .
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
env_file:
- .env
# networks:
# - remnawave-network
# volumes:
# - ./locales:/app/locales
restart: unless-stopped
# Optionally, expose ports if you are not using a shared Docker network
# and want to access the bot's webserver directly (not recommended for production without a reverse proxy)
# ports:
# - "8080:8080"
networks:
remnawave-network:
external: true # Assumes 'remnawave-network' is an existing external Docker network
# If not, you might want to define it here or use a default bridge.
postgres:
image: postgres:17
container_name: remnawave-tg-shop-db
env_file:
- .env
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
# networks:
# - remnawave-network
restart: unless-stopped
# networks:
# remnawave-network:
# external: true
volumes:
remnawave-tg-shop-db-data:
name: remnawave-tg-shop-db-data
```
**Note on `remnawave-network`:** The `docker-compose.yml` assumes an external network named `remnawave-network`. If this network doesn't exist or you want the bot on a different network (e.g., a default bridge or a new one defined in this compose file), you'll need to adjust the `networks` section. If the Remnawave panel is also running in Docker on the same host, putting them on the same user-defined network allows them to communicate using service names.
@@ -196,17 +207,21 @@ networks:
│ ├── states/ # FSM states
│ └── main_bot.py # Core bot logic, dispatcher setup, startup/shutdown
├── config/
│ └── settings.py # Pydantic settings model
│ └── settings.py # Pydantic settings and config parser
├── db/
── database.py # Database schema, connection, and CRUD functions
├── locales/ # Localization files (en.json, ru.json)
├── .env.example # Example environment variables
├── .env # Your local environment variables (ignored by git)
├── Dockerfile # Instructions to build the Docker image
├── docker-compose.yml # Docker Compose configuration
├── requirements.txt # Python dependencies
── main.py # Main entry point to run the bot
── dal/ # Data Access Layer (queries, transactions)
│ ├── database_setup.py # DB connection/init setup
│ └── models.py # ORM models (e.g., SQLAlchemy)
├── locales/ # Localization files
│ ├── en.json # English locale
│ └── ru.json # Russian locale
├── .env.example # Example environment variables for local setup
── .env # Actual environment variables (ignored by Git)
├── Dockerfile # Docker image build instructions
├── docker-compose.yml # Docker Compose orchestration config
├── requirements.txt # List of Python dependencies
├── README.md # Project documentation
└── main.py # Entry point to launch the bot
```
## 🤝 Contributing
+113 -88
View File
@@ -11,16 +11,23 @@ from config.settings import Settings
from db.dal import user_dal, message_log_dal
from bot.states.admin_states import AdminStates
from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
from bot.keyboards.inline.admin_keyboards import (
get_broadcast_confirmation_keyboard,
get_back_to_admin_panel_keyboard,
get_admin_panel_keyboard,
)
from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_broadcast_router")
async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def broadcast_message_prompt_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -35,25 +42,28 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
try:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n))
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
except Exception as e:
logging.warning(
f"Could not edit message for broadcast prompt: {e}. Sending new."
)
await callback.message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n))
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_broadcast_message)
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
async def process_broadcast_message_handler(message: types.Message,
state: FSMContext, i18n_data: dict,
settings: Settings,
session: AsyncSession):
async def process_broadcast_message_handler(
message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
@@ -63,28 +73,35 @@ async def process_broadcast_message_handler(message: types.Message,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
broadcast_message_text = message.html_text
await state.update_data(broadcast_message=broadcast_message_text)
# Сохраняем в state исходный текст и entities
text = message.text or message.caption or ""
entities = message.entities or message.caption_entities or []
preview_snippet = (
broadcast_message_text[:200] +
"...") if len(broadcast_message_text) > 200 else broadcast_message_text
confirmation_prompt = _("admin_broadcast_confirm_prompt",
message_preview=preview_snippet)
await state.update_data(
broadcast_text=text,
broadcast_entities=entities,
)
await message.answer(confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(
current_lang, i18n),
parse_mode="HTML")
preview_snippet = (text[:200] + "...") if len(text) > 200 else text
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
await message.answer(
confirmation_prompt,
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n),
)
await state.set_state(AdminStates.confirming_broadcast)
@router.callback_query(F.data == "admin_action:main",
AdminStates.waiting_for_broadcast_message)
async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
state: FSMContext,
settings: Settings, i18n_data: dict,
session: AsyncSession):
@router.callback_query(
F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
)
async def cancel_broadcast_at_prompt_stage(
callback: types.CallbackQuery,
state: FSMContext,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
@@ -94,96 +111,104 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
try:
await callback.message.edit_text(
_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
_("admin_broadcast_cancelled_nav_back"), reply_markup=None
)
except Exception:
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
await callback.answer(_("admin_broadcast_cancelled_alert"))
await state.clear()
await callback.message.answer(_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(
i18n, current_lang, settings))
await callback.message.answer(
_(key="admin_panel_title"),
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
)
@router.callback_query(F.data.startswith("broadcast_final_action:"),
AdminStates.confirming_broadcast)
async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict, bot: Bot,
settings: Settings,
session: AsyncSession):
@router.callback_query(
F.data.startswith("broadcast_final_action:"),
AdminStates.confirming_broadcast,
)
async def confirm_broadcast_callback_handler(
callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
bot: Bot,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing broadcast confirmation.",
show_alert=True)
await callback.answer("Error processing broadcast confirmation.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
action = callback.data.split(":")[1]
user_fsm_data = await state.get_data()
broadcast_message_to_send = user_fsm_data.get("broadcast_message")
if action == "send":
if not broadcast_message_to_send:
await callback.message.edit_text(
_("admin_broadcast_error_no_message"))
text = user_fsm_data.get("broadcast_text")
entities = user_fsm_data.get("broadcast_entities", [])
if not text:
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
await state.clear()
await callback.answer(_("admin_broadcast_error_no_message_alert"),
show_alert=True)
await callback.answer(
_("admin_broadcast_error_no_message_alert"), show_alert=True
)
return
await callback.message.edit_text(_("admin_broadcast_sending_started"),
reply_markup=None)
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
await callback.answer()
user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast(
session)
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
sent_count = 0
failed_count = 0
logging.info(
f"Admin {callback.from_user.id} starting broadcast: '{broadcast_message_to_send[:50]}...' to {len(user_ids_for_broadcast)} users."
)
admin_user = callback.from_user
logging.info(
f"Admin {admin_user.id} broadcasting '{text[:50]}...' to {len(user_ids)} users."
)
for user_id_to_send in user_ids_for_broadcast:
for uid in user_ids:
try:
await bot.send_message(user_id_to_send,
broadcast_message_to_send,
parse_mode="HTML")
await bot.send_message(
chat_id=uid,
text=text,
entities=entities,
)
sent_count += 1
log_payload_sent = {
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_sent",
"content":
f"To user {user_id_to_send}: {broadcast_message_to_send[:70]}...",
"is_admin_event": True,
"target_user_id": user_id_to_send
}
await message_log_dal.create_message_log(
session, log_payload_sent)
session,
{
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_sent",
"content": f"To user {uid}: {text[:70]}...",
"is_admin_event": True,
"target_user_id": uid,
},
)
except Exception as e:
failed_count += 1
logging.warning(
f"Failed to send broadcast to user {user_id_to_send}: {type(e).__name__} - {e}"
f"Failed to send broadcast to {uid}: {type(e).__name__} {e}"
)
log_payload_failed = {
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_failed",
"content":
f"For user {user_id_to_send}: {type(e).__name__} - {str(e)[:70]}...",
"is_admin_event": True,
"target_user_id": user_id_to_send
}
await message_log_dal.create_message_log(
session, log_payload_failed)
session,
{
"user_id": admin_user.id,
"telegram_username": admin_user.username,
"telegram_first_name": admin_user.first_name,
"event_type": "admin_broadcast_failed",
"content": f"For user {uid}: {type(e).__name__} {str(e)[:70]}...",
"is_admin_event": True,
"target_user_id": uid,
},
)
await asyncio.sleep(0.05)
try:
@@ -192,17 +217,17 @@ async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
await session.rollback()
logging.error(f"Error committing broadcast logs: {e_commit}")
result_message = _("admin_broadcast_finished_stats",
sent_count=sent_count,
failed_count=failed_count)
result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count)
await callback.message.answer(
result_message,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
elif action == "cancel":
await callback.message.edit_text(
_("admin_broadcast_cancelled"),
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
)
await callback.answer()
await state.clear()
+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.referral_service import ReferralService
from bot.services.panel_api_service import PanelApiService
from bot.services.payment_service import YooKassaService
from bot.services.yookassa_service import YooKassaService
from bot.middlewares.i18n import JsonI18n
from config.settings import Settings
@@ -105,7 +105,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
subscription_months,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id)
promo_code_id_from_payment=promo_code_id,
provider="yookassa")
if not activation_details or not activation_details.get('end_date'):
logging.error(
+152 -89
View File
@@ -1,6 +1,7 @@
import logging
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
from typing import Optional, Dict, Any, Union
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,11 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings
from db.dal import payment_dal
from bot.keyboards.inline.user_keyboards import (
get_subscription_options_keyboard, get_confirm_subscription_keyboard,
get_subscription_options_keyboard, get_payment_method_keyboard,
get_payment_url_keyboard, get_back_to_main_menu_markup)
from bot.services.payment_service import YooKassaService
from bot.services.yookassa_service import YooKassaService
from bot.services.stars_service import StarsService
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n
router = Router(name="user_subscription_router")
@@ -98,28 +101,70 @@ async def select_subscription_period_callback_handler(
return
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
confirmation_text_content = get_text("confirm_subscription_prompt",
months=months,
price=f"{price_rub:.2f}",
currency_symbol=currency_symbol_val)
reply_markup = get_confirm_subscription_keyboard(months, price_rub,
currency_symbol_val,
current_lang, i18n)
text_content = get_text("choose_payment_method")
tribute_url = settings.tribute_payment_links.get(months)
stars_price = settings.stars_subscription_options.get(months)
reply_markup = get_payment_method_keyboard(
months,
price_rub,
tribute_url,
stars_price,
currency_symbol_val,
current_lang,
i18n,
settings,
)
try:
await callback.message.edit_text(confirmation_text_content,
await callback.message.edit_text(text_content,
reply_markup=reply_markup)
except Exception as e_edit:
logging.warning(
f"Edit message for subscription confirmation failed: {e_edit}. Sending new one."
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
)
await callback.message.answer(confirmation_text_content,
await callback.message.answer(text_content,
reply_markup=reply_markup)
await callback.answer()
@router.callback_query(F.data.startswith("confirm_sub:"))
async def confirm_subscription_callback_handler(
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
session: AsyncSession, bot: Bot, stars_service: StarsService):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, price_str = data_payload.split(":")
months = int(months_str)
stars_price = int(price_str)
except (ValueError, IndexError):
logging.error(f"Invalid pay_stars data in callback: {callback.data}")
await callback.answer(get_text("error_try_again"), show_alert=True)
return
user_id = callback.from_user.id
payment_description = get_text("payment_description_subscription", months=months)
payment_id = await stars_service.create_invoice(
session, user_id, months, stars_price, payment_description)
if payment_id is None:
await callback.message.edit_text(get_text("error_payment_gateway"))
await callback.answer(get_text("error_try_again"), show_alert=True)
return
await callback.answer()
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
yookassa_service: YooKassaService, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -150,7 +195,7 @@ async def confirm_subscription_callback_handler(
price_rub = float(price_str)
except (ValueError, IndexError):
logging.error(
f"Invalid confirmation data in callback: {callback.data}")
f"Invalid pay_yk data in callback: {callback.data}")
await callback.answer(get_text("error_try_again"), show_alert=True)
return
@@ -257,97 +302,115 @@ async def reshow_subscription_options_callback(callback: types.CallbackQuery,
async def my_subscription_command_handler(
event: Union[types.Message, types.CallbackQuery], i18n_data: dict,
settings: Settings, panel_service: PanelApiService,
subscription_service: SubscriptionService, session: AsyncSession,
bot: Bot):
target_message_obj = event.message if isinstance(
event, types.CallbackQuery) else event
user = event.from_user
if isinstance(event, types.CallbackQuery):
await event.answer()
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
session: AsyncSession,
bot: Bot
):
target = event.message if isinstance(event, types.CallbackQuery) else event
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
) if i18n else key
i18n: JsonI18n = i18n_data.get("i18n_instance")
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
if not i18n or not target_message_obj:
if not i18n or not target:
if isinstance(event, types.Message):
await event.answer(get_text("error_occurred_try_again"))
return
if not panel_service or not subscription_service:
logging.error(
"PanelService or SubscriptionService is missing in my_subscription_command_handler."
)
await target_message_obj.answer(get_text("error_service_unavailable"))
await target.answer(get_text("error_service_unavailable"))
return
active_sub_details = await subscription_service.get_active_subscription_details(
session, user.id)
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
sub_info_text_content = ""
if active_sub_details:
end_date_obj = active_sub_details.get('end_date')
days_left = 0
if end_date_obj:
if end_date_obj.tzinfo is None:
end_date_obj = end_date_obj.replace(tzinfo=timezone.utc)
days_left = (end_date_obj.date() - datetime.now().date()).days
if not active:
text = get_text("subscription_not_active")
actual_config_link = active_sub_details.get('config_link') or get_text(
"config_link_not_available")
buy_button = InlineKeyboardButton(
text=get_text("menu_subscribe_inline", default="Купить"),
callback_data="main_action:subscribe"
)
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
traffic_limit_bytes = active_sub_details.get('traffic_limit_bytes')
traffic_used_bytes = active_sub_details.get('traffic_used_bytes')
kb = InlineKeyboardMarkup(
inline_keyboard=[
[buy_button],
*back_markup.inline_keyboard
]
)
traffic_limit_gb_str = get_text("traffic_unlimited")
if traffic_limit_bytes and traffic_limit_bytes > 0:
traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB"
if isinstance(event, types.CallbackQuery):
await event.answer()
try:
await event.message.edit_text(text, reply_markup=kb)
except:
await event.message.answer(text, reply_markup=kb)
else:
await event.answer(text, reply_markup=kb)
return
traffic_used_gb_str = get_text("traffic_na")
if traffic_used_bytes is not None:
traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB"
end_date = active.get("end_date")
days_left = (
(end_date.date() - datetime.now().date()).days
if end_date else 0
)
text = get_text(
"my_subscription_details",
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
days_left=max(0, days_left),
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
config_link=active.get("config_link") or get_text("config_link_not_available"),
traffic_limit=(
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
if active.get("traffic_limit_bytes")
else get_text("traffic_unlimited")
),
traffic_used=(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
if active.get("traffic_used_bytes") is not None
else get_text("traffic_na")
)
)
markup = get_back_to_main_menu_markup(current_lang, i18n)
sub_info_text_content = get_text(
"my_subscription_details",
end_date=end_date_obj.strftime("%Y-%m-%d")
if end_date_obj else "N/A",
days_left=max(0, days_left),
status=active_sub_details.get(
'status_from_panel', get_text('status_active')).capitalize(),
config_link=actual_config_link,
traffic_limit=traffic_limit_gb_str,
traffic_used=traffic_used_gb_str)
else:
sub_info_text_content = get_text("subscription_not_active")
logging.info(
f"User {user.id} no active sub details for 'my_subscription'.")
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
if isinstance(event, types.CallbackQuery) and event.message:
if isinstance(event, types.CallbackQuery):
await event.answer()
try:
await event.message.edit_text(sub_info_text_content,
reply_markup=reply_markup_val,
parse_mode="HTML",
disable_web_page_preview=True)
except Exception as e_edit:
logging.warning(
f"Edit 'my_subscription' failed: {e_edit}. Sending new message to chat {target_message_obj.chat.id}."
)
await bot.send_message(chat_id=target_message_obj.chat.id,
text=sub_info_text_content,
reply_markup=reply_markup_val,
parse_mode="HTML",
disable_web_page_preview=True)
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
except:
await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
else:
await target_message_obj.answer(sub_info_text_content,
reply_markup=reply_markup_val,
parse_mode="HTML",
disable_web_page_preview=True)
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
@router.pre_checkout_query()
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
await pre_checkout_query.answer(ok=True)
@router.message(F.successful_payment)
async def stars_successful_payment_handler(
message: types.Message, settings: Settings, i18n_data: dict,
session: AsyncSession, stars_service: StarsService):
sp = message.successful_payment
if not sp or sp.currency != "XTR":
return
payload = sp.invoice_payload or ""
try:
payment_id_str, months_str = payload.split(":")
payment_db_id = int(payment_id_str)
months = int(months_str)
except (ValueError, IndexError):
logging.error(f"Invalid invoice payload for stars payment: {payload}")
return
stars_amount = sp.total_amount
await stars_service.process_successful_payment(
session, message, payment_db_id, months, stars_amount, i18n_data)
@router.message(Command("connect"))
+14 -9
View File
@@ -108,16 +108,21 @@ def get_subscription_options_keyboard(subscription_options: Dict[
return builder.as_markup()
def get_confirm_subscription_keyboard(months: int, price: float,
currency_symbol_val: str, lang: str,
i18n_instance) -> InlineKeyboardMarkup:
def get_payment_method_keyboard(months: int, price: float,
tribute_url: Optional[str],
stars_price: Optional[int],
currency_symbol_val: str, lang: str,
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
confirm_text = _(key="confirm_payment_button",
price=price,
currency_symbol=currency_symbol_val)
builder.button(text=confirm_text,
callback_data=f"confirm_sub:{months}:{price}")
if settings.STARS_ENABLED and stars_price is not None:
builder.button(text=_("pay_with_stars_button"),
callback_data=f"pay_stars:{months}:{stars_price}")
if settings.TRIBUTE_ENABLED and tribute_url:
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
if settings.YOOKASSA_ENABLED:
builder.button(text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{months}:{price}")
builder.button(text=_(key="cancel_button"),
callback_data="main_action:subscribe")
builder.adjust(1)
@@ -159,5 +164,5 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
return None
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="contact_support_button"), url=support_link)
builder.button(text=_(key="menu_support_button"), url=support_link)
return builder.as_markup()
+39 -20
View File
@@ -26,11 +26,13 @@ from bot.handlers.admin import admin_router_aggregate
from bot.filters.admin_filter import AdminFilter
from bot.services.notification_service import schedule_subscription_notifications
from bot.services.payment_service import YooKassaService
from bot.services.yookassa_service import YooKassaService
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.services.promo_code_service import PromoCodeService
from bot.services.stars_service import StarsService
from bot.services.tribute_service import TributeService, tribute_webhook_route
from bot.handlers.user import payment as user_payment_webhook_module
@@ -89,30 +91,29 @@ async def on_startup_configured(dispatcher: Dispatcher):
async_session_factory: sessionmaker = dispatcher["async_session_factory"]
logging.info("STARTUP: on_startup_configured executing...")
scheduler = AsyncIOScheduler(timezone="UTC")
try:
await schedule_subscription_notifications(bot, settings, i18n_instance,
scheduler, panel_service,
async_session_factory)
scheduler.start()
dispatcher["scheduler"] = scheduler
logging.info("STARTUP: APScheduler started.")
except Exception as e:
logging.error(f"STARTUP: Failed to start APScheduler: {e}",
exc_info=True)
existing_scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
if existing_scheduler and existing_scheduler.running:
logging.warning(
"STARTUP: Scheduler already running, skipping initialization.")
else:
scheduler = AsyncIOScheduler(timezone="UTC")
try:
await schedule_subscription_notifications(
bot, settings, i18n_instance, scheduler, panel_service,
async_session_factory)
scheduler.start()
dispatcher["scheduler"] = scheduler
logging.info("STARTUP: APScheduler started.")
except Exception as e:
logging.error(
f"STARTUP: Failed to start APScheduler: {e}", exc_info=True)
telegram_webhook_url_to_set = getattr(settings,
'TELEGRAM_WEBHOOK_BASE_URL', None)
if telegram_webhook_url_to_set:
if settings.BOT_TOKEN in telegram_webhook_url_to_set:
logging.error(
f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). "
"This is a major security vulnerability. Webhook will NOT be set."
)
full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED"
else:
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
logging.info(
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
@@ -238,6 +239,12 @@ async def run_bot(settings_param: Settings):
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["yookassa_service"] = yookassa_service
@@ -245,6 +252,8 @@ async def run_bot(settings_param: Settings):
dp["subscription_service"] = subscription_service
dp["referral_service"] = referral_service
dp["promo_code_service"] = promo_code_service
dp["stars_service"] = stars_service
dp["tribute_service"] = tribute_service
dp["async_session_factory"] = local_async_session_factory
dp.update.outer_middleware(
@@ -298,6 +307,8 @@ async def run_bot(settings_param: Settings):
app['subscription_service'] = subscription_service
app['referral_service'] = referral_service
app['panel_service'] = panel_service
app['stars_service'] = stars_service
app['tribute_service'] = tribute_service
setup_application(app, dp, bot=bot)
@@ -328,6 +339,14 @@ async def run_bot(settings_param: Settings):
logging.info(
f"YooKassa webhook route configured at: [POST] {yk_path}")
tribute_path = settings_param.tribute_webhook_path
if tribute_path.startswith('/'):
app.router.add_post(
tribute_path,
tribute_webhook_route)
logging.info(
f"Tribute webhook route configured at: [POST] {tribute_path}")
web_app_runner = web.AppRunner(app)
await web_app_runner.setup()
site = web.TCPSite(web_app_runner,
+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}")
+5 -2
View File
@@ -318,7 +318,8 @@ class SubscriptionService:
months: int,
payment_amount: float,
payment_db_id: int,
promo_code_id_from_payment: Optional[int] = None
promo_code_id_from_payment: Optional[int] = None,
provider: str = "yookassa"
) -> Optional[Dict[str, Any]]:
db_user = await user_dal.get_user_by_id(session, user_id)
@@ -386,6 +387,8 @@ class SubscriptionService:
"status_from_panel": "ACTIVE",
"traffic_limit_bytes":
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
"provider": provider,
"skip_notifications": provider == "tribute",
}
try:
new_or_updated_sub = await subscription_dal.upsert_subscription(
@@ -601,7 +604,7 @@ class SubscriptionService:
session, days_threshold)
results = []
for sub_model in subs_models_with_users:
if sub_model.user and sub_model.end_date:
if sub_model.user and sub_model.end_date and not sub_model.skip_notifications:
days_left = (sub_model.end_date - datetime.now(
timezone.utc)).total_seconds() / (24 * 3600)
results.append({
+156
View File
@@ -0,0 +1,156 @@
import logging
import hmac
import hashlib
import json
from typing import Optional
from aiohttp import web
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_service import SubscriptionService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from db.dal import payment_dal, user_dal, subscription_dal
class TributeService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
async_session_factory: sessionmaker,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.panel_service = panel_service
self.subscription_service = subscription_service
self.referral_service = referral_service
async def handle_webhook(self, raw_body: bytes,
signature_header: Optional[str]) -> web.Response:
settings = self.settings
bot = self.bot
i18n = self.i18n
async_session_factory = self.async_session_factory
subscription_service = self.subscription_service
referral_service = self.referral_service
if settings.TRIBUTE_API_KEY:
if not signature_header:
return web.Response(status=403, text="no_signature")
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.Response(status=403, text="invalid_signature")
try:
payload = json.loads(raw_body.decode())
except Exception:
return web.Response(status=400, text="bad_request")
event_name = payload.get('name')
data = payload.get('payload', {})
user_id = data.get('telegram_user_id')
price_val = data.get('price')
if not user_id or price_val is None:
return web.Response(status=200, text="ok_missing_fields")
months_map = {int(v): m for m, v in settings.subscription_options.items()}
price_rub = price_val / 100
months = months_map.get(int(price_rub))
if not months:
logging.warning(
f"Tribute webhook: price {price_val} not mapped to months")
return web.Response(status=200, text="ok_price_unmapped")
async with async_session_factory() as session:
if event_name == 'new_subscription':
payment_record = await payment_dal.create_payment_record(
session,
{
'user_id': user_id,
'amount': float(price_rub),
'currency': 'RUB',
'status': 'succeeded',
'description': 'Tribute subscription',
'subscription_duration_months': months,
'provider_payment_id': str(data.get('subscription_id')),
'provider': 'tribute',
},
)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
months,
float(price_rub),
payment_record.payment_id,
provider='tribute',
)
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session, user_id, months)
await session.commit()
db_user = await user_dal.get_user_by_id(session, user_id)
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
final_end = (referral_bonus.get('referee_new_end_date')
if referral_bonus else None)
if not final_end:
final_end = activation_details.get('end_date')
if final_end:
if applied_ref_days:
inviter_name_display = _('friend_placeholder')
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter and inviter.first_name:
inviter_name_display = inviter.first_name
elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}"
success_msg = _(
"payment_successful_with_referral_bonus",
months=months,
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_ref_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display)
else:
success_msg = _(
"payment_successful", months=months,
end_date=final_end.strftime('%Y-%m-%d'))
try:
await bot.send_message(user_id, success_msg)
except Exception as e:
logging.error(
f"Failed to send Tribute payment success message to user {user_id}: {e}")
elif event_name == 'cancelled_subscription':
db_user = await user_dal.get_user_by_id(session, user_id)
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
try:
await bot.send_message(user_id, _("subscription_cancelled_notification"))
except Exception as e:
logging.warning(
f"Failed to notify user {user_id} about cancellation: {e}")
await subscription_dal.set_skip_notifications_for_provider(
session, user_id, 'tribute', False)
await session.commit()
else:
await session.commit()
return web.Response(status=200, text="ok")
async def tribute_webhook_route(request: web.Request):
"""AIOHTTP route handler for Tribute webhook calls."""
tribute_service: TributeService = request.app['tribute_service']
raw_body = await request.read()
signature_header = request.headers.get('trbt-signature')
return await tribute_service.handle_webhook(raw_body, signature_header)
+73 -12
View File
@@ -36,10 +36,31 @@ class Settings(BaseSettings):
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
PRICE_1_MONTH: Optional[int] = Field(default=None)
PRICE_3_MONTHS: Optional[int] = Field(default=None)
PRICE_6_MONTHS: Optional[int] = Field(default=None)
PRICE_12_MONTHS: Optional[int] = Field(default=None)
YOOKASSA_ENABLED: bool = Field(default=True)
STARS_ENABLED: bool = Field(default=True)
TRIBUTE_ENABLED: bool = Field(default=True)
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7)
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 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
@property
def subscription_options(self) -> Dict[int, float]:
options: Dict[int, float] = {}
if self.PRICE_1_MONTH is not None:
options[1] = float(self.PRICE_1_MONTH / 100.0)
if self.PRICE_3_MONTHS is not None:
options[3] = float(self.PRICE_3_MONTHS / 100.0)
if self.PRICE_6_MONTHS is not None:
options[6] = float(self.PRICE_6_MONTHS / 100.0)
if self.PRICE_12_MONTHS is not None:
options[12] = float(self.PRICE_12_MONTHS / 100.0)
if self.MONTH_1_ENABLED and self.RUB_PRICE_1_MONTH is not None:
options[1] = float(self.RUB_PRICE_1_MONTH)
if self.MONTH_3_ENABLED and self.RUB_PRICE_3_MONTHS is not None:
options[3] = float(self.RUB_PRICE_3_MONTHS)
if self.MONTH_6_ENABLED and self.RUB_PRICE_6_MONTHS is not None:
options[6] = float(self.RUB_PRICE_6_MONTHS)
if self.MONTH_12_ENABLED and self.RUB_PRICE_12_MONTHS is not None:
options[12] = float(self.RUB_PRICE_12_MONTHS)
return options
@computed_field
@property
def stars_subscription_options(self) -> Dict[int, int]:
options: Dict[int, int] = {}
if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None:
options[1] = self.STARS_PRICE_1_MONTH
if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None:
options[3] = self.STARS_PRICE_3_MONTHS
if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None:
options[6] = self.STARS_PRICE_6_MONTHS
if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None:
options[12] = self.STARS_PRICE_12_MONTHS
return options
@computed_field
@property
def tribute_payment_links(self) -> Dict[int, str]:
links: Dict[int, str] = {}
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
links[1] = self.TRIBUTE_LINK_1_MONTH
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
links[3] = self.TRIBUTE_LINK_3_MONTHS
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
links[6] = self.TRIBUTE_LINK_6_MONTHS
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
links[12] = self.TRIBUTE_LINK_12_MONTHS
return links
@computed_field
@property
def referral_bonus_inviter(self) -> Dict[int, int]:
+20
View File
@@ -113,3 +113,23 @@ async def get_recent_payment_logs_with_user(session: AsyncSession,
Payment.created_at.desc()).limit(limit).offset(offset))
result = await session.execute(stmt)
return result.scalars().all()
async def update_provider_payment_and_status(
session: AsyncSession, payment_db_id: int,
provider_payment_id: str, new_status: str) -> Optional[Payment]:
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.status = new_status
payment.provider_payment_id = provider_payment_id
payment.updated_at = func.now()
await session.flush()
await session.refresh(payment)
logging.info(
f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}."
)
else:
logging.warning(
f"Payment record with DB ID {payment_db_id} not found for provider update."
)
return payment
+14 -1
View File
@@ -156,7 +156,9 @@ async def get_subscriptions_near_expiration(
threshold_date = now_utc + timedelta(days=days_threshold)
stmt = (select(Subscription).join(Subscription.user).where(
Subscription.is_active == True, Subscription.end_date > now_utc,
Subscription.is_active == True,
Subscription.skip_notifications == False,
Subscription.end_date > now_utc,
Subscription.end_date <= threshold_date,
or_(
Subscription.last_notification_sent == None,
@@ -203,3 +205,14 @@ async def find_subscription_for_notification_update(
<= subscription_end_date_to_match + timedelta(seconds=1)).limit(1)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def set_skip_notifications_for_provider(
session: AsyncSession, user_id: int, provider: str,
skip: bool) -> int:
stmt = (update(Subscription).where(
Subscription.user_id == user_id,
Subscription.is_active == True,
Subscription.provider == provider).values(skip_notifications=skip))
result = await session.execute(stmt)
return result.rowcount
+4
View File
@@ -70,6 +70,8 @@ class Subscription(Base):
traffic_limit_bytes = Column(BigInteger, nullable=True)
traffic_used_bytes = Column(BigInteger, nullable=True)
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
provider = Column(String, nullable=True)
skip_notifications = Column(Boolean, default=False)
user = relationship("User", back_populates="subscriptions")
@@ -89,6 +91,8 @@ class Payment(Base):
unique=True,
index=True,
nullable=True)
provider_payment_id = Column(String, unique=True, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=True)
amount = Column(Float, nullable=False)
currency = Column(String, nullable=False)
+11 -10
View File
@@ -6,26 +6,27 @@ services:
hostname: remnawave-tg-shop
env_file:
- .env
# networks:
# - remnawave-network
# volumes:
# - ./locales:/app/locales
networks:
- remnawave-network
volumes:
- ./locales:/app/locales
restart: unless-stopped
postgres:
remnawave-tg-shop-db:
image: postgres:17
container_name: remnawave-tg-shop-db
hostname: remnawave-tg-shop-db
env_file:
- .env
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
# networks:
# - remnawave-network
networks:
- remnawave-network
restart: unless-stopped
# networks:
# remnawave-network:
# external: true
networks:
remnawave-network:
external: true
volumes:
remnawave-tg-shop-db-data:
+6 -3
View File
@@ -22,9 +22,11 @@
"select_subscription_period": "Select subscription period:",
"no_subscription_options_available": "No subscription options available at the moment.",
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
"confirm_subscription_prompt": "Confirm subscription purchase:\nDuration: {months} mo.\nPrice: {price} {currency_symbol}",
"choose_payment_method": "Choose payment method:",
"pay_button": "💳 Pay",
"confirm_payment_button": " Yes ({price} {currency_symbol})",
"pay_with_yookassa_button": "💳 YooKassa",
"pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Telegram Stars",
"cancel_button": "❌ Cancel",
"payment_description_subscription": "Subscription payment for {months} mo.",
"payment_service_unavailable": "Payment service temporarily unavailable. Please try again later.",
@@ -166,7 +168,7 @@
"user_card_sub_na": "n/a",
"user_card_ban_button": "🚫 Ban",
"user_card_unban_button": "✅ Unban",
"user_card_back_to_banned_list": "⬅️ Back to Ban List",
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
"admin_confirm_action_title": "Confirm: {action_text}",
"ban_verb_l": "ban",
"unban_verb_l": "unban",
@@ -201,6 +203,7 @@
"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_cancelled_notification": "Your recurring subscription was cancelled. You will keep access until the paid period ends.",
"error_unknown": "An unknown error occurred."
}
+7 -4
View File
@@ -22,9 +22,11 @@
"select_subscription_period": "Выберите срок подписки:",
"no_subscription_options_available": "В данный момент нет доступных вариантов подписки.",
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
"confirm_subscription_prompt": "Подтвердите покупку подписки:\nСрок: {months} мес.\nЦена: {price} {currency_symbol}",
"choose_payment_method": "Выберите способ оплаты:",
"pay_button": "💳 Оплатить",
"confirm_payment_button": "✅ Да ({price} {currency_symbol})",
"pay_with_yookassa_button": "💳 ЮKassa",
"pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Звезды Telegram",
"cancel_button": "❌ Отмена",
"payment_description_subscription": "Оплата подписки на {months} мес.",
"payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.",
@@ -88,7 +90,7 @@
"admin_stats_button": "📊 Статистика",
"admin_broadcast_button": "📢 Рассылка",
"admin_create_promo_button": "🎁 Создать промо",
"admin_view_promos_button": "👀 Спиок промо",
"admin_view_promos_button": "👀 Список промо",
"admin_ban_user_button": "🚫 Забанить",
"admin_unban_user_button": "✅ Разбанить",
"admin_view_banned_users_button": "📜 Бан-лист",
@@ -166,7 +168,7 @@
"user_card_sub_na": "н/д",
"user_card_ban_button": "🚫 Заблокировать",
"user_card_unban_button": "✅ Разблокировать",
"user_card_back_to_banned_list": "⬅️ К списку забаненных",
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
"admin_confirm_action_title": "Подтверждение: {action_text}",
"ban_verb_l": "блокировка",
"unban_verb_l": "разблокировка",
@@ -201,6 +203,7 @@
"stub_page_display": "Страница",
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
"subscription_cancelled_notification": "Ваша подписка отменена. Доступ сохранится до конца оплаченного периода.",
"error_unknown": "Произошла неизвестная ошибка."
}
+1 -1
View File
@@ -1,6 +1,6 @@
aiogram==3.20.0
python-dotenv==1.0.1
aiohttp==3.9.5
aiohttp==3.10.11
pydantic==2.7.1
yookassa==3.5.0
pycountry==23.12.11