Compare commits

..
34 Commits
Author SHA1 Message Date
Machka PaslaandGitHub 956ec5ea71 Update requirements.txt 2025-07-25 20:25:20 +03:00
Machka PaslaandGitHub 2ef420ff47 Merge pull request #49 from machka-pasla/dependabot/pip/aiohttp-3.12.14
Bump aiohttp from 3.10.11 to 3.12.14
2025-07-25 20:23:55 +03:00
Machka PaslaandGitHub 7de1cdf9d6 Merge pull request #54 from machka-pasla/codex/use-fiat-asset-instead-of-crypto-asset
Support fiat invoices in Crypto Pay
2025-07-25 13:16:00 +03:00
Machka Pasla 860c2298bd Support fiat invoices in Crypto Pay 2025-07-25 13:15:46 +03:00
Machka PaslaandGitHub 2eff9300d3 Merge pull request #53 from machka-pasla/por3bg-codex/integrate-crypto-bot-with-crypto-pay-api
Fix CryptoPay import path
2025-07-25 12:53:48 +03:00
Machka PaslaandGitHub 1bd09be005 Merge branch 'main' into por3bg-codex/integrate-crypto-bot-with-crypto-pay-api 2025-07-25 12:53:41 +03:00
Machka Pasla ce571f6566 Fix CryptoPay import 2025-07-25 12:52:51 +03:00
Machka PaslaandGitHub 856129fce2 Merge pull request #52 from machka-pasla/codex/integrate-crypto-bot-with-crypto-pay-api
Add Crypto Pay integration
2025-07-25 12:17:36 +03:00
Machka Pasla b67f659746 Refine CryptoBot labels and .env layout 2025-07-25 12:16:39 +03:00
Machka PaslaandGitHub a102903814 hotfix 2025-07-22 21:58:19 +03:00
dependabot[bot]andGitHub 7f6cca2f59 Bump aiohttp from 3.10.11 to 3.12.14
Bumps [aiohttp](https://github.com/aio-libs/aiohttp) from 3.10.11 to 3.12.14.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.10.11...v3.12.14)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.12.14
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-21 07:25:39 +00:00
Machka PaslaandGitHub 3e6ef9de32 Merge pull request #48 from machka-pasla/l9vj54-codex/refactor-payment-success-messages
Refine payment success flow
2025-07-21 10:15:16 +03:00
Machka Pasla 9cf395c103 Use full payment messages and remove unused locales 2025-07-21 10:14:59 +03:00
Machka PaslaandGitHub 69429dce81 Merge pull request #47 from machka-pasla/codex/update-purchase-confirmation-messages
Integrate referral info into purchase message
2025-07-20 21:05:49 +03:00
Machka Pasla d3bfe774c7 Refine purchase and promo messages 2025-07-20 21:04:52 +03:00
Machka PaslaandGitHub e2df8a01c8 Merge pull request #46 from machka-pasla/codex/improve-ux-after-purchase-confirmation
Improve subscription purchase flow
2025-07-20 13:11:30 +03:00
Machka Pasla dbeee31081 Improve post-purchase message 2025-07-20 13:10:44 +03:00
Machka PaslaandGitHub aa5a68c32c Merge pull request #45 from machka-pasla/codex/refactor-subscription-notifications-style
Update subscription expiration notifications
2025-07-12 10:02:00 +07:00
Machka Pasla a7b4863889 Add subscribe reminder buttons and update notifications 2025-07-12 10:01:25 +07:00
Machka PaslaandGitHub 16a00efcc9 Merge pull request #44 from machka-pasla/codex/log-event-and-telegramid-if-available
Log panel webhook event and telegram ID
2025-07-12 00:36:33 +07:00
Machka Pasla 2779e62e43 Log panel webhook events 2025-07-12 00:35:41 +07:00
Machka PaslaandGitHub d7fdcc25cd Merge pull request #43 from machka-pasla/7ql8c7-codex/fix-webhook-without-telegramid-warning
Fix webhook user extraction and notification logic
2025-07-12 00:24:48 +07:00
Machka PaslaandGitHub 24c4bbc27e Merge branch 'main' into 7ql8c7-codex/fix-webhook-without-telegramid-warning 2025-07-12 00:24:40 +07:00
Machka Pasla 5b08ad5449 Fix panel webhook user data parsing 2025-07-12 00:22:35 +07:00
Machka PaslaandGitHub fa321426b8 Merge pull request #42 from machka-pasla/codex/fix-webhook-without-telegramid-warning
Handle panel webhook data variant
2025-07-12 00:06:28 +07:00
Machka Pasla a4839ffc63 Fix panel webhook user data parsing 2025-07-12 00:06:11 +07:00
Machka PaslaandGitHub 0aa38d3806 Merge pull request #41 from machka-pasla/qrbsms-codex/add-user-notifications-for-expiration-webhooks
Handle panel webhook variants
2025-07-11 23:41:38 +07:00
Machka Pasla c7b124ad4c Handle panel webhook variants and remove Tribute cancel 2025-07-11 23:40:33 +07:00
Machka PaslaandGitHub b5b740611c Merge pull request #39 from machka-pasla/codex/add-promo-code-activation-notifications
Add promo activation and tribute/star payment notifications
2025-07-11 19:29:29 +07:00
Machka Pasla 68459a7075 Add admin notifications for promos and non-YK payments 2025-07-11 19:28:45 +07:00
Machka PaslaandGitHub 0e84a480fc Merge pull request #38 from machka-pasla/codex/fix-unhandled-telegrambadrequest-exception
Fix HTML name escaping
2025-07-09 14:34:06 +07:00
Machka Pasla 1ac368354d Escape user names in HTML messages 2025-07-09 14:33:53 +07:00
Machka PaslaandGitHub 831e61cd99 Merge pull request #37 from machka-pasla/codex/refactor-payment-logic-for-subscription-periods
Update Tribute webhook period parsing
2025-07-08 19:09:57 +07:00
Machka Pasla 981d63c53e Update Tribute webhook period parsing 2025-07-08 19:09:43 +07:00
18 changed files with 764 additions and 265 deletions
+18 -11
View File
@@ -19,24 +19,31 @@ TERMS_OF_SERVICE_URL=https://example.com/tos
SUBSCRIPTION_MINI_APP_URL= SUBSCRIPTION_MINI_APP_URL=
START_COMMAND_DESCRIPTION= START_COMMAND_DESCRIPTION=
# Webhook Base URL (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
# YooKassa Payment Gateway Configuration # YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id YOOKASSA_SHOP_ID=your_shop_id
YOOKASSA_SECRET_KEY=your_secret_key YOOKASSA_SECRET_KEY=your_secret_key
YOOKASSA_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
YOOKASSA_RETURN_URL=https://t.me/your_bot YOOKASSA_RETURN_URL=https://t.me/your_bot
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com
YOOKASSA_VAT_CODE=1 YOOKASSA_VAT_CODE=1
YOOKASSA_PAYMENT_MODE=full_prepayment YOOKASSA_PAYMENT_MODE=full_prepayment
YOOKASSA_PAYMENT_SUBJECT=payment YOOKASSA_PAYMENT_SUBJECT=payment
# Telegram Webhook Configuration (Optional) # CryptoBot Payment Gateway Configuration
# If unset, bot will use polling while YooKassa uses webhooks. CRYPTOPAY_TOKEN=
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld CRYPTOPAY_NETWORK=mainnet
CRYPTOPAY_CURRENCY_TYPE=fiat
CRYPTOPAY_ASSET=RUB
# API key for verifying Tribute webhook signatures
TRIBUTE_API_KEY=
# Payment Methods # Payment Method Toggles
YOOKASSA_ENABLED=True YOOKASSA_ENABLED=True
STARS_ENABLED=True STARS_ENABLED=True
TRIBUTE_ENABLED=True TRIBUTE_ENABLED=True
CRYPTOPAY_ENABLED=True
# Subscription Options # Subscription Options
1_MONTH_ENABLED=True 1_MONTH_ENABLED=True
@@ -58,13 +65,12 @@ TRIBUTE_LINK_6_MONTHS=
RUB_PRICE_12_MONTHS=900 RUB_PRICE_12_MONTHS=900
STARS_PRICE_12_MONTHS=0 STARS_PRICE_12_MONTHS=0
TRIBUTE_LINK_12_MONTHS= TRIBUTE_LINK_12_MONTHS=
# API key for verifying Tribute webhook signatures
TRIBUTE_API_KEY=
# Subscription Expiration Notifications # Subscription Notifications
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7 SUBSCRIPTION_NOTIFICATIONS_ENABLED=True
SUBSCRIPTION_NOTIFICATION_HOUR_UTC=13 SUBSCRIPTION_NOTIFY_ON_EXPIRE=True
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC=0 SUBSCRIPTION_NOTIFY_AFTER_EXPIRE=True
SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3
# Referral Bonus Days # Referral Bonus Days
REFERRAL_BONUS_DAYS_1_MONTH=3 REFERRAL_BONUS_DAYS_1_MONTH=3
@@ -79,6 +85,7 @@ REFEREE_BONUS_DAYS_12_MONTHS=15
# Panel API Configuration # Panel API Configuration
PANEL_API_URL=http://your_panel_api_url/api PANEL_API_URL=http://your_panel_api_url/api
PANEL_API_KEY=your_panel_api_key PANEL_API_KEY=your_panel_api_key
PANEL_WEBHOOK_SECRET= # secret used to verify panel webhook signatures
# User traffic limits (applied for all users) # User traffic limits (applied for all users)
# 0 means unlimited # 0 means unlimited
+10 -6
View File
@@ -15,6 +15,7 @@ This Telegram bot is designed to automate the sale and management of subscriptio
* **Subscription Management:** * **Subscription Management:**
* Handles subscription purchases for various periods (1, 3, 6, 12 months). * Handles subscription purchases for various periods (1, 3, 6, 12 months).
* Integrates with **YooKassa** for payment processing, including fiscal receipt data. * Integrates with **YooKassa** for payment processing, including fiscal receipt data.
* Supports **Crypto Pay** for payments with fiat currency (RUB by default).
* Automatic subscription activation/extension upon successful payment. * Automatic subscription activation/extension upon successful payment.
* Link and syncs users with a **Remnawave panel** account, primarily matching by Telegram ID. * Link and syncs users with a **Remnawave panel** account, primarily matching by Telegram ID.
* Updates user status, expiration dates, traffic limits, and inbounds on the Remnawave panel. * Updates user status, expiration dates, traffic limits, and inbounds on the Remnawave panel.
@@ -88,20 +89,21 @@ This Telegram bot is designed to automate the sale and management of subscriptio
* **YooKassa Settings:** * **YooKassa Settings:**
* `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa. * `YOOKASSA_SHOP_ID`: Your shop ID from YooKassa.
* `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa. * `YOOKASSA_SECRET_KEY`: Your secret key from YooKassa.
* `YOOKASSA_WEBHOOK_BASE_URL`: Your publicly accessible HTTPS base URL where the bot will listen for YooKassa webhooks (e.g., `https://your.domain.com`). The full path will be `{YOOKASSA_WEBHOOK_BASE_URL}/webhook/yookassa`. * `WEBHOOK_BASE_URL`: Base URL for all webhooks (Telegram, YooKassa, Crypto Pay). Example: `https://webhooks.yourdomain.com`.
* `YOOKASSA_RETURN_URL`: (Optional) URL user is redirected to after payment, often `https://t.me/your_bot_username`. * `YOOKASSA_RETURN_URL`: (Optional) URL user is redirected to after payment, often `https://t.me/your_bot_username`.
* `YOOKASSA_DEFAULT_RECEIPT_EMAIL`: **Important for 54-FZ (Russian fiscalization).** A default email for sending fiscal receipts. * `YOOKASSA_DEFAULT_RECEIPT_EMAIL`: **Important for 54-FZ (Russian fiscalization).** A default email for sending fiscal receipts.
* `YOOKASSA_VAT_CODE`: VAT code for items in receipt (e.g., `1` for "No VAT". Consult YooKassa documentation and tax advisor). * `YOOKASSA_VAT_CODE`: VAT code for items in receipt (e.g., `1` for "No VAT". Consult YooKassa documentation and tax advisor).
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`. * `YOOKASSA_PAYMENT_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. * **Crypto Pay Settings:** `CRYPTOPAY_TOKEN`, `CRYPTOPAY_NETWORK` (`mainnet` or `testnet`), `CRYPTOPAY_CURRENCY_TYPE` (`fiat` or `crypto`), `CRYPTOPAY_ASSET` (e.g., `RUB`). Enable with `CRYPTOPAY_ENABLED`.
* **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`. * **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`, `CRYPTOPAY_ENABLED`.
* **Subscription Options:** For each duration you can use variables like * **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` `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`). (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://remnawave: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_WEBHOOK_SECRET`: Secret key for verifying webhooks from the Remnawave panel.
* `USER_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users. * `USER_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users.
* `USER_TRAFFIC_LIMIT_GB` and `USER_TRAFFIC_STRATEGY`: Default traffic limit in gigabytes (0 for unlimited) and the reset strategy applied when updating users on the panel. * `USER_TRAFFIC_LIMIT_GB` and `USER_TRAFFIC_STRATEGY`: Default traffic limit in gigabytes (0 for unlimited) and the reset strategy applied when updating users on the panel.
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period. * `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
@@ -119,9 +121,11 @@ This Telegram bot is designed to automate the sale and management of subscriptio
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://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/yookassa` (where `remnawave-tg-shop` is the service name in `docker-compose.yml`). * Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/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>`. * Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/cryptopay` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/cryptopay`.
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `TELEGRAM_WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`. * Forward requests for `https://{WEBHOOK_BASE_URL_domain}/webhook/tribute` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/webhook/tribute`.
* If using Telegram webhooks, forward requests for `https://{WEBHOOK_BASE_URL_domain}/<YOUR_BOT_TOKEN>` to `http://remnawave-tg-shop:{WEB_SERVER_PORT}/<YOUR_BOT_TOKEN>`.
* **Telegram Webhook Registration:** The bot attempts to set its Telegram webhook URL on startup if `WEBHOOK_BASE_URL` is configured in `.env`. Check the bot logs to confirm if this was successful. You can also manually check using the Telegram Bot API method `getWebhookInfo`.
6. **Database:** 6. **Database:**
* 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. * 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.
+32 -13
View File
@@ -21,6 +21,7 @@ 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
from bot.services.notification_service import notify_admin_new_payment from bot.services.notification_service import notify_admin_new_payment
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
payment_processing_lock = asyncio.Lock() payment_processing_lock = asyncio.Lock()
@@ -134,7 +135,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE user_lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
success_message = "" config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
if applied_referee_bonus_days_from_referral and final_end_date_for_user: if applied_referee_bonus_days_from_referral and final_end_date_for_user:
inviter_name_display = _("friend_placeholder") inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id: if db_user and db_user.referred_by_id:
@@ -145,35 +149,50 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
elif inviter and inviter.username: elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}" inviter_name_display = f"@{inviter.username}"
success_message = _( details_message = _(
"payment_successful_with_referral_bonus", "payment_successful_with_referral_bonus_full",
months=subscription_months, months=subscription_months,
base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'), base_end_date=base_subscription_end_date.strftime('%Y-%m-%d'),
bonus_days=applied_referee_bonus_days_from_referral, bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'), final_end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display) inviter_name=inviter_name_display,
config_link=config_link,
)
elif applied_promo_bonus_days > 0 and final_end_date_for_user: elif applied_promo_bonus_days > 0 and final_end_date_for_user:
success_message = _( details_message = _(
"payment_successful_with_promo", "payment_successful_with_promo_full",
months=subscription_months, months=subscription_months,
bonus_days=applied_promo_bonus_days, bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime('%Y-%m-%d')) end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
)
elif final_end_date_for_user: elif final_end_date_for_user:
success_message = _( details_message = _(
"payment_successful", "payment_successful_full",
months=subscription_months, months=subscription_months,
end_date=final_end_date_for_user.strftime('%Y-%m-%d')) end_date=final_end_date_for_user.strftime('%Y-%m-%d'),
config_link=config_link,
)
else: else:
logging.error( logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic."
) )
success_message = _("payment_successful_error_details") details_message = _("payment_successful_error_details")
details_markup = get_connect_and_main_keyboard(
user_lang, i18n, settings, config_link
)
try: try:
await bot.send_message(user_id, success_message) await bot.send_message(
user_id,
details_message,
reply_markup=details_markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e_notify: except Exception as e_notify:
logging.error( logging.error(
f"Failed to send final payment success message to user {user_id}: {e_notify}" f"Failed to send payment details message to user {user_id}: {e_notify}"
) )
await notify_admin_new_payment( await notify_admin_new_payment(
+31 -8
View File
@@ -10,7 +10,11 @@ from config.settings import Settings
from bot.states.user_states import UserPromoStates from bot.states.user_states import UserPromoStates
from bot.services.promo_code_service import PromoCodeService from bot.services.promo_code_service import PromoCodeService
from bot.services.subscription_service import SubscriptionService from bot.services.subscription_service import SubscriptionService
from bot.keyboards.inline.user_keyboards import get_back_to_main_menu_markup from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_connect_and_main_keyboard,
)
from datetime import datetime
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from .start import send_main_menu from .start import send_main_menu
@@ -65,6 +69,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
async def process_promo_code_input(message: types.Message, state: FSMContext, async def process_promo_code_input(message: types.Message, state: FSMContext,
settings: Settings, i18n_data: dict, settings: Settings, i18n_data: dict,
promo_code_service: PromoCodeService, promo_code_service: PromoCodeService,
subscription_service: SubscriptionService,
bot: Bot, session: AsyncSession): bot: Bot, session: AsyncSession):
logging.info( logging.info(
f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'" f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'"
@@ -126,24 +131,42 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
code=hcode(code_input.upper())) code=hcode(code_input.upper()))
else: else:
success, response_text_from_service = await promo_code_service.apply_promo_code( success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang) session, user.id, code_input, current_lang)
response_to_user_text = response_text_from_service
if success: if success:
await session.commit() await session.commit()
logging.info( logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}." f"Promo code '{code_input}' successfully applied for user {user.id}."
) )
new_end_date = result if isinstance(result, datetime) else None
active = await subscription_service.get_active_subscription_details(session, user.id)
config_link = active.get("config_link") if active else None
config_link = config_link or _("config_link_not_available")
response_to_user_text = _(
"promo_code_applied_success_full",
end_date=(new_end_date.strftime("%d.%m.%Y %H:%M:%S") if new_end_date else "N/A"),
config_link=config_link,
)
reply_markup = get_connect_and_main_keyboard(
current_lang, i18n, settings, config_link
)
else: else:
await session.rollback() await session.rollback()
logging.info( logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {response_text_from_service}" f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
) )
await message.answer(response_to_user_text, await message.answer(
reply_markup=get_back_to_main_menu_markup( response_to_user_text,
current_lang, i18n), reply_markup=reply_markup,
parse_mode="HTML") parse_mode="HTML",
)
await state.clear() await state.clear()
logging.info( logging.info(
f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared." f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared."
+3 -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.utils.text_decorations import html_decoration as hd
from aiogram.filters import CommandStart, Command from aiogram.filters import CommandStart, Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from typing import Optional, Union from typing import Optional, Union
@@ -30,7 +31,7 @@ async def send_main_menu(target_event: Union[types.Message,
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
user_id = target_event.from_user.id user_id = target_event.from_user.id
user_full_name = target_event.from_user.full_name user_full_name = hd.quote(target_event.from_user.full_name)
if not i18n: if not i18n:
logging.error( logging.error(
@@ -192,7 +193,7 @@ async def start_command_handler(message: types.Message,
f"Failed to update existing user {user_id} in session: {e_update}", f"Failed to update existing user {user_id} in session: {e_update}",
exc_info=True) exc_info=True)
await message.answer(_(key="welcome", user_name=user.full_name)) await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
await send_main_menu(message, await send_main_menu(message,
settings, settings,
i18n_data, i18n_data,
+44
View File
@@ -13,6 +13,7 @@ from bot.keyboards.inline.user_keyboards import (
get_payment_url_keyboard, get_back_to_main_menu_markup) get_payment_url_keyboard, get_back_to_main_menu_markup)
from bot.services.yookassa_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from bot.services.stars_service import StarsService from bot.services.stars_service import StarsService
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.subscription_service import SubscriptionService from bot.services.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.services.referral_service import ReferralService
@@ -293,6 +294,49 @@ async def pay_yk_callback_handler(
await callback.answer() await callback.answer()
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
cryptopay_service: CryptoPayService, session: AsyncSession):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
return
if not cryptopay_service or not cryptopay_service.configured:
await callback.message.edit_text(get_text("payment_service_unavailable"))
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
return
try:
_, data_payload = callback.data.split(":", 1)
months_str, amount_str = data_payload.split(":")
months = int(months_str)
amount_val = float(amount_str)
except (ValueError, IndexError):
logging.error(f"Invalid pay_crypto data in callback: {callback.data}")
await callback.answer(get_text("error_try_again"), show_alert=True)
return
user_id = callback.from_user.id
description = get_text("payment_description_subscription", months=months)
invoice_url = await cryptopay_service.create_invoice(
session, user_id, months, amount_val, description)
if invoice_url:
await callback.message.edit_text(
get_text("payment_link_message", months=months),
reply_markup=get_payment_url_keyboard(invoice_url, current_lang, i18n),
disable_web_page_preview=False,
)
else:
await callback.message.edit_text(get_text("error_payment_gateway"))
await callback.answer()
@router.callback_query(F.data == "main_action:subscribe") @router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery, async def reshow_subscription_options_callback(callback: types.CallbackQuery,
i18n_data: dict, i18n_data: dict,
+49
View File
@@ -134,6 +134,9 @@ def get_payment_method_keyboard(months: int, price: float,
if settings.YOOKASSA_ENABLED: if settings.YOOKASSA_ENABLED:
builder.button(text=_("pay_with_yookassa_button"), builder.button(text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{months}:{price}") callback_data=f"pay_yk:{months}:{price}")
if settings.CRYPTOPAY_ENABLED:
builder.button(text=_("pay_with_cryptopay_button"),
callback_data=f"pay_crypto:{months}:{price}")
builder.button(text=_(key="cancel_button"), builder.button(text=_(key="cancel_button"),
callback_data="main_action:subscribe") callback_data="main_action:subscribe")
builder.adjust(1) builder.adjust(1)
@@ -169,6 +172,14 @@ def get_back_to_main_menu_markup(lang: str,
return builder.as_markup() return builder.as_markup()
def get_subscribe_only_markup(lang: str, i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe")
return builder.as_markup()
def get_user_banned_keyboard(support_link: Optional[str], lang: str, def get_user_banned_keyboard(support_link: Optional[str], lang: str,
i18n_instance) -> Optional[InlineKeyboardMarkup]: i18n_instance) -> Optional[InlineKeyboardMarkup]:
if not support_link: if not support_link:
@@ -177,3 +188,41 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text=_(key="menu_support_button"), url=support_link) builder.button(text=_(key="menu_support_button"), url=support_link)
return builder.as_markup() return builder.as_markup()
def get_connect_and_main_keyboard(
lang: str,
i18n_instance,
settings: Settings,
config_link: Optional[str]) -> InlineKeyboardMarkup:
"""Keyboard with a connect button and a back to main menu button."""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
text=_("connect_button"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
elif config_link:
builder.row(
InlineKeyboardButton(text=_("connect_button"), url=config_link)
)
else:
builder.row(
InlineKeyboardButton(
text=_("connect_button"),
callback_data="main_action:my_subscription",
)
)
builder.row(
InlineKeyboardButton(
text=_("back_to_main_menu_button"),
callback_data="main_action:back_to_main",
)
)
return builder.as_markup()
+38 -37
View File
@@ -16,7 +16,7 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
from aiohttp import web from aiohttp import web
from apscheduler.schedulers.asyncio import AsyncIOScheduler from bot.services.panel_webhook_service import PanelWebhookService, panel_webhook_route
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from config.settings import Settings from config.settings import Settings
@@ -31,7 +31,6 @@ from bot.handlers.user import user_router_aggregate
from bot.handlers.admin import admin_router_aggregate 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.yookassa_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
@@ -39,6 +38,7 @@ 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.stars_service import StarsService
from bot.services.tribute_service import TributeService, tribute_webhook_route from bot.services.tribute_service import TributeService, tribute_webhook_route
from bot.services.crypto_pay_service import CryptoPayService, cryptopay_webhook_route
from bot.handlers.user import payment as user_payment_webhook_module from bot.handlers.user import payment as user_payment_webhook_module
@@ -101,28 +101,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
logging.info("STARTUP: on_startup_configured executing...") logging.info("STARTUP: on_startup_configured executing...")
existing_scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
if existing_scheduler and existing_scheduler.running: telegram_webhook_url_to_set = settings.WEBHOOK_BASE_URL
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 telegram_webhook_url_to_set:
full_telegram_webhook_url = ( full_telegram_webhook_url = (
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}" f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
@@ -173,7 +153,7 @@ async def on_startup_configured(dispatcher: Dispatcher):
) )
else: else:
logging.info( logging.info(
"STARTUP: TELEGRAM_WEBHOOK_BASE_URL not set in environment. Attempting to delete any existing webhook (running in polling mode)." "STARTUP: WEBHOOK_BASE_URL not set in environment. Running in polling mode and clearing any existing webhook."
) )
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
@@ -213,15 +193,6 @@ async def on_startup_configured(dispatcher: Dispatcher):
async def on_shutdown_configured(dispatcher: Dispatcher): async def on_shutdown_configured(dispatcher: Dispatcher):
logging.warning("SHUTDOWN: on_shutdown_configured executing...") logging.warning("SHUTDOWN: on_shutdown_configured executing...")
scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
if scheduler and scheduler.running:
try:
scheduler.shutdown(wait=False)
logging.info("SHUTDOWN: APScheduler shut down.")
except Exception as e:
logging.error(
f"SHUTDOWN: Error shutting down APScheduler: {e}", exc_info=True
)
panel_service: Optional[PanelApiService] = dispatcher.get("panel_service") 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"):
@@ -295,6 +266,16 @@ async def run_bot(settings_param: Settings):
stars_service = StarsService( stars_service = StarsService(
bot, settings_param, i18n_instance, subscription_service, referral_service bot, settings_param, i18n_instance, subscription_service, referral_service
) )
cryptopay_service = CryptoPayService(
settings_param.CRYPTOPAY_TOKEN,
settings_param.CRYPTOPAY_NETWORK,
bot,
settings_param,
i18n_instance,
local_async_session_factory,
subscription_service,
referral_service,
)
tribute_service = TributeService( tribute_service = TributeService(
bot, bot,
settings_param, settings_param,
@@ -304,6 +285,12 @@ async def run_bot(settings_param: Settings):
subscription_service, subscription_service,
referral_service, referral_service,
) )
panel_webhook_service = PanelWebhookService(
bot,
settings_param,
i18n_instance,
local_async_session_factory,
)
dp["i18n_instance"] = i18n_instance dp["i18n_instance"] = i18n_instance
dp["yookassa_service"] = yookassa_service dp["yookassa_service"] = yookassa_service
@@ -312,7 +299,9 @@ async def run_bot(settings_param: Settings):
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["stars_service"] = stars_service
dp["cryptopay_service"] = cryptopay_service
dp["tribute_service"] = tribute_service dp["tribute_service"] = tribute_service
dp["panel_webhook_service"] = panel_webhook_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(DBSessionMiddleware(local_async_session_factory))
@@ -329,8 +318,8 @@ 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", None) tg_webhook_base = settings_param.WEBHOOK_BASE_URL
yk_webhook_base = getattr(settings_param, "YOOKASSA_WEBHOOK_BASE_URL", None) yk_webhook_base = settings_param.WEBHOOK_BASE_URL
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)
@@ -341,10 +330,10 @@ async def run_bot(settings_param: Settings):
logging.info(f"--- Bot Run Mode Decision ---") logging.info(f"--- Bot Run Mode Decision ---")
logging.info( logging.info(
f"Configured TELEGRAM_WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}" f"Configured WEBHOOK_BASE_URL: '{tg_webhook_base}' -> Telegram Webhook Mode: {telegram_uses_webhook_mode}"
) )
logging.info( logging.info(
f"Configured YOOKASSA_WEBHOOK_BASE_URL: '{yk_webhook_base}' & Path: '{settings_param.yookassa_webhook_path}'" f"YooKassa webhook path: '{settings_param.yookassa_webhook_path}'"
) )
logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp_server}") logging.info(f"Decision: Run AIOHTTP server: {should_run_aiohttp_server}")
logging.info(f"Decision: Run Telegram Polling: {run_telegram_polling}") logging.info(f"Decision: Run Telegram Polling: {run_telegram_polling}")
@@ -366,7 +355,9 @@ async def run_bot(settings_param: Settings):
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["stars_service"] = stars_service
app["cryptopay_service"] = cryptopay_service
app["tribute_service"] = tribute_service app["tribute_service"] = tribute_service
app["panel_webhook_service"] = panel_webhook_service
setup_application(app, dp, bot=bot) setup_application(app, dp, bot=bot)
@@ -402,6 +393,16 @@ async def run_bot(settings_param: Settings):
app.router.add_post(tribute_path, tribute_webhook_route) app.router.add_post(tribute_path, tribute_webhook_route)
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}") logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
cp_path = settings_param.cryptopay_webhook_path
if cp_path.startswith("/"):
app.router.add_post(cp_path, cryptopay_webhook_route)
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
panel_path = settings_param.panel_webhook_path
if panel_path.startswith("/"):
app.router.add_post(panel_path, panel_webhook_route)
logging.info(f"Panel webhook route configured at: [POST] {panel_path}")
web_app_runner = web.AppRunner(app) web_app_runner = web.AppRunner(app)
await web_app_runner.setup() await web_app_runner.setup()
site = web.TCPSite( site = web.TCPSite(
+206
View File
@@ -0,0 +1,206 @@
import logging
import json
from typing import Optional
from aiogram import Bot
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from aiocryptopay import AioCryptoPay, Networks
from aiocryptopay.models.update import Update
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.services.subscription_service import SubscriptionService
from bot.services.referral_service import ReferralService
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import notify_admin_new_payment
from db.dal import payment_dal, user_dal
class CryptoPayService:
def __init__(
self,
token: Optional[str],
network: str,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
if token:
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
self.client = AioCryptoPay(token=token, network=net)
self.client.register_pay_handler(self._invoice_paid_handler)
self.configured = True
else:
logging.warning("CryptoPay token not provided. CryptoPay disabled")
self.client = None
self.configured = False
async def create_invoice(
self,
session: AsyncSession,
user_id: int,
months: int,
amount: float,
description: str,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": months,
"provider": "cryptopay",
},
)
payload = json.dumps({
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
})
try:
invoice = await self.client.create_invoice(
amount=amount,
currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE,
fiat=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat" else None,
asset=self.settings.CRYPTOPAY_ASSET if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto" else None,
description=description,
payload=payload,
)
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(invoice.invoice_id),
str(invoice.status),
)
return invoice.bot_invoice_url
except Exception as e:
logging.error(f"CryptoPay invoice creation failed: {e}", exc_info=True)
return None
async def _invoice_paid_handler(self, update: Update, app: web.Application):
invoice = update.payload
if not invoice.payload:
logging.warning("CryptoPay webhook without payload")
return
try:
meta = json.loads(invoice.payload)
user_id = int(meta["user_id"])
months = int(meta["subscription_months"])
payment_db_id = int(meta["payment_db_id"])
except Exception as e:
logging.error(f"Failed to parse CryptoPay payload: {e}")
return
async_session_factory: sessionmaker = app["async_session_factory"]
bot: Bot = app["bot"]
settings: Settings = app["settings"]
i18n: JsonI18n = app["i18n"]
subscription_service: SubscriptionService = app["subscription_service"]
referral_service: ReferralService = app["referral_service"]
async with async_session_factory() as session:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_db_id,
str(invoice.invoice_id),
"succeeded",
)
activation = await subscription_service.activate_subscription(
session,
user_id,
months,
float(invoice.amount),
payment_db_id,
provider="cryptopay",
)
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session, user_id, months
)
await session.commit()
except Exception as e:
await session.rollback()
logging.error(f"Failed to process CryptoPay invoice: {e}", exc_info=True)
return
db_user = await user_dal.get_user_by_id(session, user_id)
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
config_link = activation.get("subscription_url") or _("config_link_not_available")
final_end = activation.get("end_date")
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
if applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter and inviter.first_name:
inviter_name_display = inviter.first_name
elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}"
text = _("payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_days,
final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display,
config_link=config_link)
else:
text = _("payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link)
markup = get_connect_and_main_keyboard(lang, i18n, settings, config_link)
try:
await bot.send_message(
user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e:
logging.error(f"Failed to send CryptoPay success message: {e}")
await notify_admin_new_payment(
bot,
settings,
i18n,
user_id,
months,
float(invoice.amount),
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
)
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured or not self.client:
return web.Response(status=503, text="cryptopay_disabled")
return await self.client.get_updates(request)
async def cryptopay_webhook_route(request: web.Request) -> web.Response:
service: CryptoPayService = request.app["cryptopay_service"]
return await service.webhook_route(request)
+16 -126
View File
@@ -1,138 +1,13 @@
import logging import logging
import asyncio import asyncio
from aiogram import Bot from aiogram import Bot
from apscheduler.schedulers.asyncio import AsyncIOScheduler from aiogram.utils.text_decorations import html_decoration as hd
from datetime import datetime, timezone from datetime import datetime, timezone
from config.settings import Settings from config.settings import Settings
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.panel_api_service import PanelApiService
from bot.services.subscription_service import SubscriptionService
async def send_expiration_warnings(bot: Bot, settings: Settings,
i18n: JsonI18n,
panel_service: PanelApiService,
async_session_factory: sessionmaker):
logging.info(
f"Scheduler job 'send_expiration_warnings' started at {datetime.now(timezone.utc)} UTC."
)
if async_session_factory is None:
logging.error(
"NotificationService: AsyncSessionFactory not provided to send_expiration_warnings!"
)
return
async with async_session_factory() as session:
try:
sub_service = SubscriptionService(settings, panel_service)
expiring_subs_details_list = await sub_service.get_subscriptions_ending_soon(
session, settings.SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS)
if not expiring_subs_details_list:
logging.info(
"No subscriptions found ending soon for notification.")
return
logging.info(
f"Found {len(expiring_subs_details_list)} subscriptions for expiration warning."
)
for sub_details in expiring_subs_details_list:
user_id = sub_details['user_id']
user_lang = sub_details.get('language_code',
settings.DEFAULT_LANGUAGE)
first_name = sub_details.get('first_name', f"User {user_id}")
end_date_str_for_msg = sub_details.get('end_date_str', "N/A")
days_left_display = sub_details.get('days_left', "N/A")
subscription_actual_end_date_obj: Optional[
datetime] = sub_details.get(
'subscription_end_date_iso_for_update')
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
)
message_text = _("subscription_ending_soon_notification",
user_name=first_name,
end_date=end_date_str_for_msg,
days_left=days_left_display)
try:
await bot.send_message(user_id, message_text)
logging.info(
f"Sent expiration warning to user {user_id} for subscription ending {end_date_str_for_msg}."
)
if subscription_actual_end_date_obj:
await sub_service.update_last_notification_sent(
session, user_id, subscription_actual_end_date_obj)
else:
logging.warning(
f"Could not find exact subscription end_date_obj for user {user_id} to update notification time."
)
except Exception as e:
logging.error(
f"Failed to send expiration warning or update notification status for user {user_id}: {e}",
exc_info=True)
await asyncio.sleep(0.1)
await session.commit()
logging.info(
"Finished processing expiration warnings. Session committed.")
except Exception as e_session:
logging.error(
f"Error during send_expiration_warnings session: {e_session}",
exc_info=True)
await session.rollback()
logging.info(
"Session rolled back due to error in send_expiration_warnings."
)
async def schedule_subscription_notifications(
bot: Bot, settings: Settings, i18n: JsonI18n,
scheduler: AsyncIOScheduler, panel_service: PanelApiService,
async_session_factory: sessionmaker):
async def job_wrapper():
try:
await send_expiration_warnings(bot, settings, i18n, panel_service,
async_session_factory)
except Exception as e:
logging.error(
f"Unhandled error in scheduled job 'send_expiration_warnings' (job_wrapper): {e}",
exc_info=True)
try:
notification_hour = int(settings.SUBSCRIPTION_NOTIFICATION_HOUR_UTC)
notification_minute = int(
settings.SUBSCRIPTION_NOTIFICATION_MINUTE_UTC)
except (ValueError, TypeError):
logging.warning(
"SUBSCRIPTION_NOTIFICATION_HOUR_UTC or MINUTE_UTC is invalid in settings. Defaulting to 9:00 UTC."
)
notification_hour = 9
notification_minute = 0
scheduler.add_job(job_wrapper,
'cron',
hour=notification_hour,
minute=notification_minute,
name="daily_subscription_expiration_warnings_v2",
misfire_grace_time=60 * 15,
replace_existing=True)
logging.info(
f"Subscription expiration warning job scheduled daily at {notification_hour:02d}:{notification_minute:02d} UTC."
)
async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n, async def notify_admins(bot: Bot, settings: Settings, i18n: JsonI18n,
@@ -176,3 +51,18 @@ async def notify_admin_new_payment(bot: Bot, settings: Settings, i18n: JsonI18n,
amount=f"{amount:.2f}", amount=f"{amount:.2f}",
currency=currency_symbol, currency=currency_symbol,
) )
async def notify_admin_promo_activation(bot: Bot, settings: Settings,
i18n: JsonI18n, user_id: int,
code: str,
bonus_days: int) -> None:
await notify_admins(
bot,
settings,
i18n,
"admin_promo_activation_notification",
user_id=user_id,
code=code,
bonus_days=bonus_days,
)
+131
View File
@@ -0,0 +1,131 @@
import json
import logging
import hmac
import hashlib
from aiohttp import web
from aiogram import Bot
from aiogram.types import InlineKeyboardMarkup
from sqlalchemy.orm import sessionmaker
from typing import Optional
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.keyboards.inline.user_keyboards import get_subscribe_only_markup
from db.dal import user_dal
EVENT_MAP = {
"user.expires_in_72_hours": (3, "subscription_72h_notification"),
"user.expires_in_48_hours": (2, "subscription_48h_notification"),
"user.expires_in_24_hours": (1, "subscription_24h_notification"),
}
class PanelWebhookService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n, async_session_factory: sessionmaker):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
async def _send_message(
self,
user_id: int,
lang: str,
message_key: str,
reply_markup: InlineKeyboardMarkup | None = None,
**kwargs,
):
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw)
try:
await self.bot.send_message(
user_id, _(message_key, **kwargs), reply_markup=reply_markup
)
except Exception as e:
logging.error(f"Failed to send notification to {user_id}: {e}")
async def handle_event(self, event_name: str, user_payload: dict):
telegram_id = user_payload.get("telegramId")
if not telegram_id:
logging.warning("Panel webhook without telegramId received")
return
user_id = int(telegram_id)
if not self.settings.SUBSCRIPTION_NOTIFICATIONS_ENABLED:
return
async with self.async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
first_name = db_user.first_name or f"User {user_id}" if db_user else f"User {user_id}"
markup = get_subscribe_only_markup(lang, self.i18n)
if event_name in EVENT_MAP:
days_left, msg_key = EVENT_MAP[event_name]
if days_left <= self.settings.SUBSCRIPTION_NOTIFY_DAYS_BEFORE:
await self._send_message(
user_id,
lang,
msg_key,
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired" and self.settings.SUBSCRIPTION_NOTIFY_ON_EXPIRE:
await self._send_message(
user_id,
lang,
"subscription_expired_notification",
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
elif event_name == "user.expired_24_hours_ago" and self.settings.SUBSCRIPTION_NOTIFY_AFTER_EXPIRE:
await self._send_message(
user_id,
lang,
"subscription_expired_yesterday_notification",
reply_markup=markup,
user_name=first_name,
end_date=user_payload.get("expireAt", "")[:10],
)
async def handle_webhook(self, raw_body: bytes, signature_header: Optional[str]) -> web.Response:
if self.settings.PANEL_WEBHOOK_SECRET:
if not signature_header:
return web.Response(status=403, text="no_signature")
expected_sig = hmac.new(
self.settings.PANEL_WEBHOOK_SECRET.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_sig, signature_header):
return web.Response(status=403, text="invalid_signature")
try:
payload = json.loads(raw_body.decode())
except Exception:
return web.Response(status=400, text="bad_request")
event_name = payload.get("name") or payload.get("event")
user_data = payload.get("payload") or payload.get("data", {})
if isinstance(user_data, dict) and "user" in user_data:
user_data = user_data.get("user") or user_data
telegram_id = user_data.get("telegramId") if isinstance(user_data, dict) else None
if not event_name:
return web.Response(status=200, text="ok_no_event")
logging.info(
"Panel webhook event received: %s; telegramId=%s",
event_name,
telegram_id if telegram_id is not None else "N/A",
)
await self.handle_event(event_name, user_data)
return web.Response(status=200, text="ok")
async def panel_webhook_route(request: web.Request):
service: PanelWebhookService = request.app["panel_webhook_service"]
raw = await request.read()
signature_header = request.headers.get("X-Remnawave-Signature")
return await service.handle_webhook(raw, signature_header)
+18 -8
View File
@@ -1,4 +1,5 @@
import logging import logging
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict from typing import Optional, Tuple, Dict
from aiogram import Bot from aiogram import Bot
@@ -10,6 +11,7 @@ from db.models import PromoCode, User
from .subscription_service import SubscriptionService from .subscription_service import SubscriptionService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from .notification_service import notify_admin_promo_activation
class PromoCodeService: class PromoCodeService:
@@ -22,9 +24,13 @@ class PromoCodeService:
self.bot = bot self.bot = bot
self.i18n = i18n self.i18n = i18n
async def apply_promo_code(self, session: AsyncSession, user_id: int, async def apply_promo_code(
code_input: str, self,
user_lang: str) -> Tuple[bool, str]: session: AsyncSession,
user_id: int,
code_input: str,
user_lang: str,
) -> Tuple[bool, datetime | str]:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw) _ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper() code_input_upper = code_input.strip().upper()
@@ -56,11 +62,15 @@ class PromoCodeService:
session, promo_data.promo_code_id) session, promo_data.promo_code_id)
if activation_recorded and promo_incremented: if activation_recorded and promo_incremented:
await notify_admin_promo_activation(
return True, _("promo_code_applied_success", self.bot,
code=code_input_upper, self.settings,
bonus_days=bonus_days, self.i18n,
new_end_date=new_end_date.strftime('%Y-%m-%d')) user_id,
code_input_upper,
bonus_days,
)
return True, new_end_date
else: else:
logging.error( logging.error(
+34 -4
View File
@@ -10,6 +10,8 @@ from db.dal import payment_dal, user_dal
from .subscription_service import SubscriptionService from .subscription_service import SubscriptionService
from .referral_service import ReferralService from .referral_service import ReferralService
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from .notification_service import notify_admin_new_payment
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
class StarsService: class StarsService:
@@ -107,6 +109,10 @@ class StarsService:
i18n: JsonI18n = i18n_data.get("i18n_instance") i18n: JsonI18n = i18n_data.get("i18n_instance")
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k _ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
if applied_days: if applied_days:
inviter_name_display = _("friend_placeholder") inviter_name_display = _("friend_placeholder")
db_user = await user_dal.get_user_by_id(session, message.from_user.id) db_user = await user_dal.get_user_by_id(session, message.from_user.id)
@@ -117,19 +123,43 @@ class StarsService:
elif inviter and inviter.username: elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}" inviter_name_display = f"@{inviter.username}"
success_msg = _( success_msg = _(
"payment_successful_with_referral_bonus", "payment_successful_with_referral_bonus_full",
months=months, months=months,
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'), base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_days, bonus_days=applied_days,
final_end_date=final_end.strftime('%Y-%m-%d'), final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display, inviter_name=inviter_name_display,
config_link=config_link,
) )
else: else:
success_msg = _("payment_successful", months=months, success_msg = _(
end_date=final_end.strftime('%Y-%m-%d')) "payment_successful_full",
months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link,
)
markup = get_connect_and_main_keyboard(
current_lang, i18n, self.settings, config_link
)
try: try:
await self.bot.send_message(message.from_user.id, success_msg) await self.bot.send_message(
message.from_user.id,
success_msg,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e_send: except Exception as e_send:
logging.error( logging.error(
f"Failed to send stars payment success message: {e_send}") f"Failed to send stars payment success message: {e_send}")
await notify_admin_new_payment(
self.bot,
self.settings,
self.i18n,
message.from_user.id,
months,
float(stars_amount),
currency="XTR",
)
+62 -24
View File
@@ -13,9 +13,31 @@ from bot.middlewares.i18n import JsonI18n
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.services.referral_service import ReferralService
from .notification_service import notify_admin_new_payment
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from db.dal import payment_dal, user_dal, subscription_dal from db.dal import payment_dal, user_dal, subscription_dal
def convert_period_to_months(period: Optional[str]) -> int:
"""Map Tribute subscription period strings to months."""
if not period:
return 1
mapping = {
"monthly": 1,
"quarterly": 3,
"3-month": 3,
"3months": 3,
"3-months": 3,
"q": 3,
"halfyearly": 6,
"yearly": 12,
"annual": 12,
"y": 12,
}
return mapping.get(period.lower(), 1)
class TributeService: class TributeService:
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n, def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
async_session_factory: sessionmaker, async_session_factory: sessionmaker,
@@ -55,18 +77,18 @@ class TributeService:
event_name = payload.get('name') event_name = payload.get('name')
data = payload.get('payload', {}) data = payload.get('payload', {})
user_id = data.get('telegram_user_id') user_id = data.get('telegram_user_id')
price_val = data.get('price') price_val = (
data.get('amount')
or data.get('amount_paid')
or data.get('price')
)
if not user_id or price_val is None: if not user_id or price_val is None:
return web.Response(status=200, text="ok_missing_fields") return web.Response(status=200, text="ok_missing_fields")
months_map = {int(v): m for m, v in settings.subscription_options.items()} period_val = data.get('period')
months = convert_period_to_months(period_val)
price_rub = price_val / 100 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: async with async_session_factory() as session:
if event_name == 'new_subscription': if event_name == 'new_subscription':
@@ -116,6 +138,10 @@ class TributeService:
final_end = activation_details.get('end_date') final_end = activation_details.get('end_date')
if final_end: if final_end:
config_link = activation_details.get("subscription_url") or _(
"config_link_not_available"
)
if applied_ref_days: if applied_ref_days:
inviter_name_display = _('friend_placeholder') inviter_name_display = _('friend_placeholder')
if db_user and db_user.referred_by_id: if db_user and db_user.referred_by_id:
@@ -125,34 +151,46 @@ class TributeService:
elif inviter and inviter.username: elif inviter and inviter.username:
inviter_name_display = f"@{inviter.username}" inviter_name_display = f"@{inviter.username}"
success_msg = _( success_msg = _(
"payment_successful_with_referral_bonus", "payment_successful_with_referral_bonus_full",
months=months, months=months,
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'), base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
bonus_days=applied_ref_days, bonus_days=applied_ref_days,
final_end_date=final_end.strftime('%Y-%m-%d'), final_end_date=final_end.strftime('%Y-%m-%d'),
inviter_name=inviter_name_display) inviter_name=inviter_name_display,
config_link=config_link,
)
else: else:
success_msg = _( success_msg = _(
"payment_successful", months=months, "payment_successful_full",
end_date=final_end.strftime('%Y-%m-%d')) months=months,
end_date=final_end.strftime('%Y-%m-%d'),
config_link=config_link,
)
markup = get_connect_and_main_keyboard(
lang, i18n, settings, config_link
)
try: try:
await bot.send_message(user_id, success_msg) await bot.send_message(
user_id,
success_msg,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e: except Exception as e:
logging.error( logging.error(
f"Failed to send Tribute payment success message to user {user_id}: {e}") 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) await notify_admin_new_payment(
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE bot,
_ = lambda k, **kw: i18n.gettext(lang, k, **kw) settings,
try: i18n,
await bot.send_message(user_id, _("subscription_cancelled_notification")) user_id,
except Exception as e: months,
logging.warning( float(price_rub),
f"Failed to notify user {user_id} about cancellation: {e}") currency="RUB",
await subscription_dal.set_skip_notifications_for_provider( )
session, user_id, 'tribute', False)
await session.commit()
else: else:
await session.commit() await session.commit()
return web.Response(status=200, text="ok") return web.Response(status=200, text="ok")
+44 -10
View File
@@ -26,7 +26,6 @@ class Settings(BaseSettings):
YOOKASSA_SHOP_ID: Optional[str] = None YOOKASSA_SHOP_ID: Optional[str] = None
YOOKASSA_SECRET_KEY: Optional[str] = None YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_WEBHOOK_BASE_URL: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None YOOKASSA_RETURN_URL: Optional[str] = None
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None) YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None)
@@ -34,7 +33,13 @@ class Settings(BaseSettings):
YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment") YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment")
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service") YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None WEBHOOK_BASE_URL: Optional[str] = None
CRYPTOPAY_TOKEN: Optional[str] = None
CRYPTOPAY_NETWORK: str = Field(default="mainnet")
CRYPTOPAY_CURRENCY_TYPE: str = Field(default="fiat")
CRYPTOPAY_ASSET: str = Field(default="RUB")
CRYPTOPAY_ENABLED: bool = Field(default=True)
YOOKASSA_ENABLED: bool = Field(default=True) YOOKASSA_ENABLED: bool = Field(default=True)
STARS_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True)
@@ -61,10 +66,12 @@ class Settings(BaseSettings):
TRIBUTE_LINK_6_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_LINK_12_MONTHS: Optional[str] = Field(default=None)
TRIBUTE_API_KEY: Optional[str] = Field(default=None) TRIBUTE_API_KEY: Optional[str] = Field(default=None)
PANEL_WEBHOOK_SECRET: Optional[str] = Field(default=None)
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True)
SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9) SUBSCRIPTION_NOTIFY_ON_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFICATION_MINUTE_UTC: int = Field(default=0) SUBSCRIPTION_NOTIFY_AFTER_EXPIRE: bool = Field(default=True)
SUBSCRIPTION_NOTIFY_DAYS_BEFORE: int = Field(default=3)
REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field( REFERRAL_BONUS_DAYS_INVITER_1_MONTH: Optional[int] = Field(
default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH") default=3, alias="REFERRAL_BONUS_DAYS_1_MONTH")
@@ -167,9 +174,9 @@ class Settings(BaseSettings):
@computed_field @computed_field
@property @property
def yookassa_full_webhook_url(self) -> Optional[str]: def yookassa_full_webhook_url(self) -> Optional[str]:
if self.YOOKASSA_WEBHOOK_BASE_URL: base = self.WEBHOOK_BASE_URL
if base:
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}" return f"{base.rstrip('/')}{self.yookassa_webhook_path}"
return None return None
@computed_field @computed_field
@@ -180,8 +187,35 @@ class Settings(BaseSettings):
@computed_field @computed_field
@property @property
def tribute_full_webhook_url(self) -> Optional[str]: def tribute_full_webhook_url(self) -> Optional[str]:
if self.YOOKASSA_WEBHOOK_BASE_URL: base = self.WEBHOOK_BASE_URL
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.tribute_webhook_path}" if base:
return f"{base.rstrip('/')}{self.tribute_webhook_path}"
return None
@computed_field
@property
def panel_webhook_path(self) -> str:
return "/webhook/panel"
@computed_field
@property
def panel_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.panel_webhook_path}"
return None
@computed_field
@property
def cryptopay_webhook_path(self) -> str:
return "/webhook/cryptopay"
@computed_field
@property
def cryptopay_full_webhook_url(self) -> Optional[str]:
base = self.WEBHOOK_BASE_URL
if base:
return f"{base.rstrip('/')}{self.cryptopay_webhook_path}"
return None return None
@computed_field @computed_field
+12 -6
View File
@@ -25,8 +25,10 @@
"choose_payment_method": "Choose payment method:", "choose_payment_method": "Choose payment method:",
"pay_button": "💳 Pay", "pay_button": "💳 Pay",
"pay_with_yookassa_button": "💳 YooKassa", "pay_with_yookassa_button": "💳 YooKassa",
"pay_with_cryptopay_button": "💎 CryptoBot",
"pay_with_tribute_button": "❤️ Tribute", "pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Telegram Stars", "pay_with_stars_button": "🌟 Telegram Stars",
"connect_button": "🔗 Connect",
"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.",
@@ -35,10 +37,10 @@
"error_payment_gateway_link_failed": "Failed to get payment link. Please contact support.", "error_payment_gateway_link_failed": "Failed to get payment link. Please contact support.",
"payment_link_message": "To pay for {months} mo. subscription, click the button below:", "payment_link_message": "To pay for {months} mo. subscription, click the button below:",
"error_payment_gateway": "Payment gateway error. Please try again later or contact support.", "error_payment_gateway": "Payment gateway error. Please try again later or contact support.",
"payment_successful": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.",
"payment_successful_with_promo": "✅ Payment successful!\nYour {months}-month subscription (with promo bonus +{bonus_days} days) is active until {end_date}.",
"payment_successful_with_referral_bonus": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.",
"payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.", "payment_successful_error_details": "✅ Payment succeeded, but an error occurred displaying details. Your subscription is active. Contact support if anything is wrong.",
"payment_successful_full": "✅ Payment successful!\nYour {months}-month subscription is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"payment_successful_with_promo_full": "✅ Payment successful!\nYour {months}-month subscription (with promo bonus +{bonus_days} days) is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"payment_successful_with_referral_bonus_full": "✅ Payment successful!\nYour {months}-month subscription (base end date: {base_end_date}) has been extended by {bonus_days} bonus days for referral from {inviter_name} and is now active until {final_end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.", "payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
"error_processing_your_payment": "An error occurred processing your payment after success. Please contact support with the details.", "error_processing_your_payment": "An error occurred processing your payment after success. Please contact support with the details.",
@@ -56,7 +58,7 @@
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.", "promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.", "promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
"promo_code_no_active_subscription": "You must have an active subscription to apply this promo code.", "promo_code_no_active_subscription": "You must have an active subscription to apply this promo code.",
"promo_code_applied_success": "✅ Promo code <code>{code}</code> applied successfully!\nYour subscription is extended by {bonus_days} days and is now active until {new_end_date}.", "promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.", "error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
"promo_input_cancelled_short": "Promo code entry cancelled.", "promo_input_cancelled_short": "Promo code entry cancelled.",
@@ -211,11 +213,15 @@
"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_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
"subscription_cancelled_notification": "Your recurring subscription was cancelled. You will keep access until the paid period ends.", "subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
"admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.", "admin_new_trial_notification": "\ud83c\udf21 User {user_id} activated a free trial until {end_date}.",
"admin_new_payment_notification": "\ud83d\udcb3 Payment received from user {user_id}: {months} mo. for {amount} {currency}.", "admin_new_payment_notification": "\ud83d\udcb3 Payment received from user {user_id}: {months} mo. for {amount} {currency}.",
"admin_promo_activation_notification": "\ud83c\udf81 Promo code {code} activated by user {user_id} (+{bonus_days}d).",
"error_unknown": "An unknown error occurred." "error_unknown": "An unknown error occurred."
} }
+12 -6
View File
@@ -25,8 +25,10 @@
"choose_payment_method": "Выберите способ оплаты:", "choose_payment_method": "Выберите способ оплаты:",
"pay_button": "💳 Оплатить", "pay_button": "💳 Оплатить",
"pay_with_yookassa_button": "💳 ЮKassa", "pay_with_yookassa_button": "💳 ЮKassa",
"pay_with_cryptopay_button": "💎 CryptoBot",
"pay_with_tribute_button": "❤️ Tribute", "pay_with_tribute_button": "❤️ Tribute",
"pay_with_stars_button": "🌟 Звезды Telegram", "pay_with_stars_button": "🌟 Звезды Telegram",
"connect_button": "🔗 Подключиться",
"cancel_button": "❌ Отмена", "cancel_button": "❌ Отмена",
"payment_description_subscription": "Оплата подписки на {months} мес.", "payment_description_subscription": "Оплата подписки на {months} мес.",
"payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.", "payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.",
@@ -35,10 +37,10 @@
"error_payment_gateway_link_failed": "Не удалось получить ссылку на оплату. Пожалуйста, свяжитесь с поддержкой.", "error_payment_gateway_link_failed": "Не удалось получить ссылку на оплату. Пожалуйста, свяжитесь с поддержкой.",
"payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:", "payment_link_message": "Для оплаты подписки на {months} мес., нажмите кнопку ниже:",
"error_payment_gateway": "Ошибка платежного шлюза. Попробуйте позже или свяжитесь с поддержкой.", "error_payment_gateway": "Ошибка платежного шлюза. Попробуйте позже или свяжитесь с поддержкой.",
"payment_successful": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.",
"payment_successful_with_promo": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (с учетом промокода на +{bonus_days} дней) активна до {end_date}.",
"payment_successful_with_referral_bonus": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.",
"payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.", "payment_successful_error_details": "✅ Оплата прошла успешно, но возникла ошибка при отображении деталей. Ваша подписка активна. Свяжитесь с поддержкой, если что-то не так.",
"payment_successful_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_successful_with_promo_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (с учетом промокода на +{bonus_days} дней) активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_successful_with_referral_bonus_full": "✅ Оплата прошла успешно!\nВаша подписка на {months} мес. (базовая дата окончания: {base_end_date}) продлена на {bonus_days} бонусных дней за приглашение от {inviter_name} и теперь активна до {final_end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.", "payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
"error_processing_your_payment": "Произошла ошибка при обработке вашего платежа после его успеха. Пожалуйста, свяжитесь с поддержкой, указав детали.", "error_processing_your_payment": "Произошла ошибка при обработке вашего платежа после его успеха. Пожалуйста, свяжитесь с поддержкой, указав детали.",
@@ -56,7 +58,7 @@
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.", "promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.", "promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
"promo_code_no_active_subscription": "Для активации этого промокода у вас должна быть активная подписка.", "promo_code_no_active_subscription": "Для активации этого промокода у вас должна быть активная подписка.",
"promo_code_applied_success": "✅ Промокод <code>{code}</code> успешно применен!\nВаша подписка продлена на {bonus_days} дней и теперь активна до {new_end_date}.", "promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.", "error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
"promo_input_cancelled_short": "Ввод промокода отменен.", "promo_input_cancelled_short": "Ввод промокода отменен.",
@@ -211,11 +213,15 @@
"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_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_cancelled_notification": "Ваша подписка отменена. Доступ сохранится до конца оплаченного периода.", "subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.",
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
"admin_new_trial_notification": "\ud83c\udf21 Пользователь {user_id} активировал пробный период до {end_date}.", "admin_new_trial_notification": "\ud83c\udf21 Пользователь {user_id} активировал пробный период до {end_date}.",
"admin_new_payment_notification": "\ud83d\udcb3 Получен платеж от пользователя {user_id}: {months} мес. за {amount} {currency}.", "admin_new_payment_notification": "\ud83d\udcb3 Получен платеж от пользователя {user_id}: {months} мес. за {amount} {currency}.",
"admin_promo_activation_notification": "\ud83c\udf81 Пользователь {user_id} активировал промокод {code} (+{bonus_days} дн.)",
"error_unknown": "Произошла неизвестная ошибка." "error_unknown": "Произошла неизвестная ошибка."
} }
+4 -4
View File
@@ -1,11 +1,11 @@
aiogram==3.20.0 aiogram==3.21.0
python-dotenv==1.0.1 python-dotenv==1.0.1
aiohttp==3.10.11 aiohttp==3.12.14
pydantic==2.7.1 pydantic==2.7.1
yookassa==3.5.0 yookassa==3.5.0
pycountry==23.12.11 pycountry==23.12.11
apscheduler==3.10.4
pydantic_settings pydantic_settings
sqlalchemy[asyncio]==2.0.29 sqlalchemy[asyncio]==2.0.29
asyncpg==0.29.0 asyncpg==0.29.0
alembic==1.13.1 alembic==1.13.1
aiocryptopay==0.4.8