diff --git a/.env.template b/.env.template index aa2803ec..c096d8ae 100644 --- a/.env.template +++ b/.env.template @@ -20,6 +20,7 @@ KRYPTO_EXPRESS_API_URL = "https://kryptoexpress.pro/api" KRYPTO_EXPRESS_API_SECRET = "" REDIS_PASSWORD = "" REDIS_HOST = "redis" +TELEGRAM_PROXY_URL = "" CRYPTO_FORWARDING_MODE = "false" BTC_FORWARDING_ADDRESS = "" LTC_FORWARDING_ADDRESS = "" @@ -38,4 +39,4 @@ TOTAL_BONUS_CAP_PERCENT = "12" SQLADMIN_RAW_PASSWORD = "" JWT_EXPIRE_MINUTES = "30" JWT_ALGORITHM = "HS256" -JWT_SECRET_KEY = "" \ No newline at end of file +JWT_SECRET_KEY = "" diff --git a/bot.py b/bot.py index 3e03eb39..e0a5c1c7 100644 --- a/bot.py +++ b/bot.py @@ -1,16 +1,15 @@ import logging import sys import traceback +from contextlib import asynccontextmanager from pathlib import Path -from aiogram.client.default import DefaultBotProperties from aiogram.fsm.storage.redis import RedisStorage from aiogram.types import BufferedInputFile, URLInputFile from redis.asyncio import Redis from sqladmin import Admin import config -from aiogram import Bot, Dispatcher -from aiogram.enums import ParseMode +from aiogram import Dispatcher from fastapi import FastAPI, Request, status, HTTPException from admin import authentication_backend @@ -37,48 +36,16 @@ from services.media import MediaService from services.notification import NotificationService from services.wallet import WalletService +from utils.telegram import create_bot, create_telegram_session from utils.utils import validate_i18n redis = Redis(host=config.REDIS_HOST, password=config.REDIS_PASSWORD) -bot = Bot(config.TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) +session = create_telegram_session() +bot = create_bot(config.TOKEN, session) dp = Dispatcher(storage=RedisStorage(redis)) -app = FastAPI() -admin = Admin(app=app, engine=engine, authentication_backend=authentication_backend) -admin.add_model_view(UserAdmin) -admin.add_model_view(BuyAdmin) -admin.add_model_view(ShippingOptionAdmin) -admin.add_model_view(CouponAdmin) -admin.add_model_view(CategoryAdmin) -admin.add_model_view(SubcategoryAdmin) -admin.add_model_view(ItemAdmin) -admin.add_model_view(DepositAdmin) -admin.add_model_view(BuyItemAdmin) -admin.add_model_view(PaymentAdmin) -admin.add_model_view(CartAdmin) -admin.add_model_view(CartItemAdmin) -admin.add_model_view(ReferralBonusAdmin) -admin.add_model_view(ReviewAdmin) - -app.include_router(processing_router) -@app.post(config.WEBHOOK_PATH) -async def webhook(request: Request): - secret_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token") - if secret_token != config.WEBHOOK_SECRET_TOKEN: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") - - try: - update_data = await request.json() - await dp.feed_webhook_update(bot, update_data) - return {"status": "ok"} - except Exception as e: - logging.error(f"Error processing webhook: {e}") - return {"status": "error"}, status.HTTP_500_INTERNAL_SERVER_ERROR - - -@app.on_event("startup") -async def on_startup(): +async def _startup() -> None: await create_db_and_tables() await bot.set_webhook( url=config.WEBHOOK_URL, @@ -110,15 +77,15 @@ async def on_startup(): await ButtonMediaRepository.init_buttons_media() if config.CRYPTO_FORWARDING_MODE: for cryptocurrency in Cryptocurrency: - is_addr_valid = WalletService.validate_withdrawal_address( - cryptocurrency.get_forwarding_address(), - cryptocurrency - ) + forwarding_address = cryptocurrency.get_forwarding_address() + is_addr_valid = WalletService.validate_withdrawal_address(forwarding_address, cryptocurrency) if is_addr_valid is False: - logging.debug( - f"Your withdrawal address for {cryptocurrency.name} cryptocurrency is not valid!" + logging.error( + "Your withdrawal address for %s cryptocurrency is not configured correctly: %s", + cryptocurrency.name, + forwarding_address ) - sys.exit() + sys.exit(1) for admin in config.ADMIN_ID_LIST: try: await bot.send_message(admin, 'Bot is working') @@ -126,14 +93,58 @@ async def on_startup(): logging.warning(e) -@app.on_event("shutdown") -async def on_shutdown(): +async def _shutdown() -> None: logging.warning('Shutting down..') await bot.delete_webhook() await dp.storage.close() + await bot.session.close() logging.warning('Bye!') +@asynccontextmanager +async def lifespan(app: FastAPI): + await _startup() + try: + yield + finally: + await _shutdown() + + +app = FastAPI(lifespan=lifespan) +admin = Admin(app=app, engine=engine, authentication_backend=authentication_backend) +admin.add_model_view(UserAdmin) +admin.add_model_view(BuyAdmin) +admin.add_model_view(ShippingOptionAdmin) +admin.add_model_view(CouponAdmin) +admin.add_model_view(CategoryAdmin) +admin.add_model_view(SubcategoryAdmin) +admin.add_model_view(ItemAdmin) +admin.add_model_view(DepositAdmin) +admin.add_model_view(BuyItemAdmin) +admin.add_model_view(PaymentAdmin) +admin.add_model_view(CartAdmin) +admin.add_model_view(CartItemAdmin) +admin.add_model_view(ReferralBonusAdmin) +admin.add_model_view(ReviewAdmin) + +app.include_router(processing_router) + + +@app.post(config.WEBHOOK_PATH) +async def webhook(request: Request): + secret_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token") + if secret_token != config.WEBHOOK_SECRET_TOKEN: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized") + + try: + update_data = await request.json() + await dp.feed_webhook_update(bot, update_data) + return {"status": "ok"} + except Exception as e: + logging.error(f"Error processing webhook: {e}") + return {"status": "error"}, status.HTTP_500_INTERNAL_SERVER_ERROR + + @app.exception_handler(Exception) async def exception_handler(request: Request, exc: Exception): traceback_str = traceback.format_exc() diff --git a/config.py b/config.py index 70e1cd36..88e3219d 100644 --- a/config.py +++ b/config.py @@ -6,7 +6,7 @@ from enums.runtime_environment import RuntimeEnvironment from utils.utils import get_sslipio_external_url, start_ngrok, hash_password -load_dotenv(".env") +load_dotenv(".env.bot.dev") RUNTIME_ENVIRONMENT = RuntimeEnvironment(os.environ.get("RUNTIME_ENVIRONMENT")) if RUNTIME_ENVIRONMENT == RuntimeEnvironment.DEV: WEBHOOK_HOST = start_ngrok() @@ -35,6 +35,7 @@ WEBHOOK_SECRET_TOKEN = os.environ.get("WEBHOOK_SECRET_TOKEN") REDIS_HOST = os.environ.get("REDIS_HOST", "redis") REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD") +TELEGRAM_PROXY_URL = os.environ.get("TELEGRAM_PROXY_URL") # VARIABLES FOR CRYPTO FORWARDING CRYPTO_FORWARDING_MODE = os.environ.get("CRYPTO_FORWARDING_MODE", False) == 'true' BTC_FORWARDING_ADDRESS = os.environ.get("BTC_FORWARDING_ADDRESS") diff --git a/handlers/admin/announcement.py b/handlers/admin/announcement.py index cdfbf518..b9bbebc7 100644 --- a/handlers/admin/announcement.py +++ b/handlers/admin/announcement.py @@ -51,8 +51,10 @@ async def send_generated_msg(**kwargs): language: Language = kwargs.get("language") kb_builder = AnnouncementsConstants.get_confirmation_builder(callback_data.announcement_type, language) - msg = await ItemService.create_announcement_message(callback_data.announcement_type, session, language) - await callback.message.answer(text=msg, reply_markup=kb_builder.as_markup()) + messages = await ItemService.create_announcement_message(callback_data.announcement_type, session, language) + for index, message_text in enumerate(messages): + reply_markup = kb_builder.as_markup() if index == 0 else None + await callback.message.answer(text=message_text, reply_markup=reply_markup) async def send_confirmation(**kwargs): diff --git a/handlers/user/my_profile.py b/handlers/user/my_profile.py index f293e2ca..8d441fed 100644 --- a/handlers/user/my_profile.py +++ b/handlers/user/my_profile.py @@ -156,10 +156,13 @@ async def receive_top_up_amount(message: Message, session, language) state_data = await state.get_data() - await message.bot.edit_message_media(chat_id=state_data.get("chat_id"), - message_id=state_data.get("msg_id"), - media=media, - reply_markup=kb_builder.as_markup()) + if state_data.get("chat_id") and state_data.get("msg_id"): + await message.bot.edit_message_media(chat_id=state_data.get("chat_id"), + message_id=state_data.get("msg_id"), + media=media, + reply_markup=kb_builder.as_markup()) + else: + await NotificationService.answer_media(message, media, kb_builder.as_markup()) @my_profile_router.callback_query(MyProfileCallback.filter(), IsUserExistFilter()) diff --git a/i18n/de.json b/i18n/de.json index f3ca9f41..2f619a5a 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -304,7 +304,8 @@ "referral_code_section": "\n\n🔗 Ihr Empfehlungslink\nt.me/{bot_username}?start={referral_code}\nTeilen Sie Ihren Link und wachsen Sie gemeinsam 🚀", "referral_button": "✨ Empfehlungssystem", "top_up_balance_deposit_msg": "💵 Überweisen Sie den gewünschten Betrag in {crypto_name} an die Adresse, um das Bot-Guthaben aufzuladen.\n\nZahlungsstatus: {status}\nZahlung gültig bis {payment_lifetime}.\n\nWichtig\nFür jede Einzahlung wird eine eindeutige {crypto_name}-Adresse vergeben\nDie Aufladung erfolgt innerhalb von 5 Minuten nach der Überweisung.\n\nNach erfolgreicher Guthabenaktualisierung erhalten Sie eine Benachrichtigung vom Bot.\n\nIhre {crypto_name}-Adresse\n{addr}", - "top_up_balance_payment_msg": "💵 Überweisen Sie {crypto_amount} {crypto_name} an die Adresse, um das Bot-Guthaben für {fiat_amount} {currency_text} aufzuladen\n\nZahlungsstatus: {status}\nZahlung gültig bis {payment_lifetime}.\n\nWichtig\nFür jede Einzahlung wird eine eindeutige {crypto_name}-Adresse vergeben\nDie Aufladung erfolgt innerhalb von 5 Minuten nach der Überweisung.\n\nNach erfolgreicher Guthabenaktualisierung erhalten Sie eine Benachrichtigung vom Bot.\n\nIhre {crypto_name}-Adresse\n{addr}", - "top_up_balance_request_fiat": "💵 Bitte senden Sie den Betrag, den Sie in {currency_text} aufladen möchten\n⚠️ Achtung! Mindesteinzahlungsbetrag 5 {currency_text}" + "top_up_balance_payment_msg": "💵 Senden Sie genau {crypto_amount} {crypto_name}\n\n⚠️ SENDEN SIE NICHT WENIGER.\n⚠️ SENDEN SIE NICHT MEHR.\n⚠️ NUR DER EXAKTE BETRAG WIRD AKZEPTIERT.\n\n❌ Unterzahlung ist verboten.\n❌ Überzahlung ist verboten.\n❗ Wenn Sie einen anderen Betrag als {crypto_amount} {crypto_name} senden, gehen Ihre Gelder dauerhaft verloren und können nicht wiederhergestellt werden.\n\nZahlungsstatus: {status}\n⏳ Gültig bis: {payment_lifetime}\n\nEinzahlungsadresse:\n{addr}\n\nℹ️ Dies ist eine eindeutige {crypto_name}-Adresse, die nur für diese Zahlung erstellt wurde.\n✅ Das Guthaben wird innerhalb von 5 Minuten nach Eingang der Transaktion gutgeschrieben.", + "top_up_balance_request_fiat": "💵 Bitte senden Sie den Betrag, den Sie in {currency_text} aufladen möchten\n⚠️ Achtung! Mindesteinzahlungsbetrag 5 {currency_text}", + "top_up_balance_invalid_fiat_amount": "⚠️ Ungültiger Betrag.\nBitte senden Sie einen positiven Betrag von 5 bis unter 1000000 {currency_text} mit höchstens 2 Dezimalstellen." } } diff --git a/i18n/en.json b/i18n/en.json index 122cfacb..6b5aa7c0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -303,7 +303,8 @@ "referral_code_section": "\n\n\uD83D\uDD17 Your Referral Link\nt.me/{bot_username}?start={referral_code}\nShare your link and grow together \uD83D\uDE80", "referral_button": "✨ Referral System", "top_up_balance_deposit_msg": "💵 Deposit to the address the amount you want in {crypto_name} to top up the balance of bot.\n\nPayment status: {status}\nPayment lifetime until {payment_lifetime}.\n\nImportant\nA unique {crypto_name} addresses is given for each deposit\nThe top up takes place within 5 minutes after the transfer.\n\nAfter a successful balance refresh, you will receive a notification from bot.\n\nYour {crypto_name} address\n{addr}", - "top_up_balance_payment_msg": "\uD83D\uDCB5 Deposit to the address the {crypto_amount} {crypto_name} to top up the balance of bot for {fiat_amount} {currency_text}\n\nPayment status: {status}\nPayment lifetime until {payment_lifetime}.\n\nImportant\nA unique {crypto_name} addresses is given for each deposit\nThe top up takes place within 5 minutes after the transfer.\n\nAfter a successful balance refresh, you will receive a notification from bot.\n\nYour {crypto_name} address\n{addr}", - "top_up_balance_request_fiat": "\uD83D\uDCB5 Please send the amount you wish to top up balance in {currency_text}\n⚠\uFE0F Attention! Minimal deposit amount 5 {currency_text}" + "top_up_balance_payment_msg": "💵 Send exactly {crypto_amount} {crypto_name}\n\n⚠️ DO NOT SEND LESS.\n⚠️ DO NOT SEND MORE.\n⚠️ ONLY THE EXACT AMOUNT IS ACCEPTED.\n\n❌ Underpayment is forbidden.\n❌ Overpayment is forbidden.\n❗ If you send any amount other than {crypto_amount} {crypto_name}, your funds will be lost permanently and cannot be recovered.\n\nPayment status: {status}\n⏳ Valid until: {payment_lifetime}\n\nDeposit address:\n{addr}\n\nℹ️ This is a unique {crypto_name} address created only for this payment.\n✅ The balance will be credited within 5 minutes after the transaction is received.", + "top_up_balance_request_fiat": "\uD83D\uDCB5 Please send the amount you wish to top up balance in {currency_text}\n⚠\uFE0F Attention! Minimal deposit amount 5 {currency_text}", + "top_up_balance_invalid_fiat_amount": "⚠️ Invalid amount.\nPlease send a positive amount from 5 to less than 1000000 {currency_text} using up to 2 decimal places." } } diff --git a/i18n/es.json b/i18n/es.json index 828e3aac..70e214c4 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -304,7 +304,8 @@ "referral_code_section": "\n\n🔗 Tu Enlace de Referido\nt.me/{bot_username}?start={referral_code}\nComparte tu enlace y crece junto con otros 🚀", "referral_button": "✨ Sistema de Referidos", "top_up_balance_deposit_msg": "💵 Deposite en la dirección la cantidad que desee en {crypto_name} para recargar el saldo del bot.\n\nEstado del pago: {status}\nVigencia del pago hasta {payment_lifetime}.\n\nImportante\nSe asigna una dirección única de {crypto_name} para cada depósito\nLa recarga se realiza en 5 minutos después de la transferencia.\n\nTras una actualización exitosa del saldo, recibirá una notificación del bot.\n\nSu dirección de {crypto_name}\n{addr}", - "top_up_balance_payment_msg": "💵 Deposite en la dirección {crypto_amount} {crypto_name} para recargar el saldo del bot por {fiat_amount} {currency_text}\n\nEstado del pago: {status}\nVigencia del pago hasta {payment_lifetime}.\n\nImportante\nSe asigna una dirección única de {crypto_name} para cada depósito\nLa recarga se realiza en 5 minutos después de la transferencia.\n\nTras una actualización exitosa del saldo, recibirá una notificación del bot.\n\nSu dirección de {crypto_name}\n{addr}", - "top_up_balance_request_fiat": "💵 Por favor, envíe la cantidad que desea recargar en {currency_text}\n⚠️ ¡Atención! Depósito mínimo 5 {currency_text}" + "top_up_balance_payment_msg": "💵 Envíe exactamente {crypto_amount} {crypto_name}\n\n⚠️ NO ENVÍE MENOS.\n⚠️ NO ENVÍE MÁS.\n⚠️ SOLO SE ACEPTA LA CANTIDAD EXACTA.\n\n❌ El pago insuficiente está prohibido.\n❌ El pago en exceso está prohibido.\n❗ Si envía cualquier cantidad distinta de {crypto_amount} {crypto_name}, sus fondos se perderán permanentemente y no podrán recuperarse.\n\nEstado del pago: {status}\n⏳ Válido hasta: {payment_lifetime}\n\nDirección de depósito:\n{addr}\n\nℹ️ Esta es una dirección única de {crypto_name} creada solo para este pago.\n✅ El saldo se acreditará dentro de los 5 minutos posteriores a la recepción de la transacción.", + "top_up_balance_request_fiat": "💵 Por favor, envíe la cantidad que desea recargar en {currency_text}\n⚠️ ¡Atención! Depósito mínimo 5 {currency_text}", + "top_up_balance_invalid_fiat_amount": "⚠️ Cantidad no válida.\nEnvíe una cantidad positiva desde 5 hasta menos de 1000000 {currency_text} usando hasta 2 decimales." } } diff --git a/i18n/fr.json b/i18n/fr.json index 513fe861..1de3a938 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -304,7 +304,8 @@ "referral_code_section": "\n\n🔗 Votre Lien de Parrainage\nt.me/{bot_username}?start={referral_code}\nPartagez votre lien et grandissez ensemble 🚀", "referral_button": "✨ Système de Parrainage", "top_up_balance_deposit_msg": "💵 Déposez sur l'adresse le montant souhaité en {crypto_name} pour recharger le solde du bot.\n\nStatut du paiement: {status}\nValidité du paiement jusqu'au {payment_lifetime}.\n\nImportant\nUne adresse {crypto_name} unique est attribuée pour chaque dépôt\nLe rechargement s'effectue dans les 5 minutes suivant le transfert.\n\nAprès une actualisation réussie du solde, vous recevrez une notification du bot.\n\nVotre adresse {crypto_name}\n{addr}", - "top_up_balance_payment_msg": "💵 Déposez sur l'adresse {crypto_amount} {crypto_name} pour recharger le solde du bot pour {fiat_amount} {currency_text}\n\nStatut du paiement: {status}\nValidité du paiement jusqu'au {payment_lifetime}.\n\nImportant\nUne adresse {crypto_name} unique est attribuée pour chaque dépôt\nLe rechargement s'effectue dans les 5 minutes suivant le transfert.\n\nAprès une actualisation réussie du solde, vous recevrez une notification du bot.\n\nVotre adresse {crypto_name}\n{addr}", - "top_up_balance_request_fiat": "💵 Veuillez envoyer le montant que vous souhaitez recharger en {currency_text}\n⚠️ Attention ! Dépôt minimum 5 {currency_text}" + "top_up_balance_payment_msg": "💵 Envoyez exactement {crypto_amount} {crypto_name}\n\n⚠️ N'ENVOYEZ PAS MOINS.\n⚠️ N'ENVOYEZ PAS PLUS.\n⚠️ SEUL LE MONTANT EXACT EST ACCEPTÉ.\n\n❌ Le sous-paiement est interdit.\n❌ Le surpaiement est interdit.\n❗ Si vous envoyez un montant différent de {crypto_amount} {crypto_name}, vos fonds seront perdus définitivement et ne pourront pas être récupérés.\n\nStatut du paiement: {status}\n⏳ Valide jusqu'au : {payment_lifetime}\n\nAdresse de dépôt :\n{addr}\n\nℹ️ Il s'agit d'une adresse {crypto_name} unique créée uniquement pour ce paiement.\n✅ Le solde sera crédité dans les 5 minutes suivant la réception de la transaction.", + "top_up_balance_request_fiat": "💵 Veuillez envoyer le montant que vous souhaitez recharger en {currency_text}\n⚠️ Attention ! Dépôt minimum 5 {currency_text}", + "top_up_balance_invalid_fiat_amount": "⚠️ Montant invalide.\nVeuillez envoyer un montant positif de 5 à moins de 1000000 {currency_text} avec au maximum 2 décimales." } } diff --git a/i18n/it.json b/i18n/it.json index 605a6b7b..0d1661b2 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -304,7 +304,8 @@ "referral_code_section": "\n\n🔗 Il tuo Link di Referral\nt.me/{bot_username}?start={referral_code}\nCondividi il tuo link e cresci insieme 🚀", "referral_button": "✨ Sistema di Referral", "top_up_balance_deposit_msg": "💵 Deposita all'indirizzo l'importo desiderato in {crypto_name} per ricaricare il saldo del bot.\n\nStato pagamento: {status}\nPagamento valido fino a {payment_lifetime}.\n\nImportante\nViene assegnato un indirizzo {crypto_name} unico per ogni deposito\nLa ricarica avviene entro 5 minuti dal trasferimento.\n\nDopo un aggiornamento riuscito del saldo, riceverai una notifica dal bot.\n\nIl tuo indirizzo {crypto_name}\n{addr}", - "top_up_balance_payment_msg": "💵 Deposita all'indirizzo {crypto_amount} {crypto_name} per ricaricare il saldo del bot per {fiat_amount} {currency_text}\n\nStato pagamento: {status}\nPagamento valido fino a {payment_lifetime}.\n\nImportante\nViene assegnato un indirizzo {crypto_name} unico per ogni deposito\nLa ricarica avviene entro 5 minuti dal trasferimento.\n\nDopo un aggiornamento riuscito del saldo, riceverai una notifica dal bot.\n\nIl tuo indirizzo {crypto_name}\n{addr}", - "top_up_balance_request_fiat": "💵 Per favore, invia l'importo che desideri ricaricare in {currency_text}\n⚠️ Attenzione! Deposito minimo 5 {currency_text}" + "top_up_balance_payment_msg": "💵 Invia esattamente {crypto_amount} {crypto_name}\n\n⚠️ NON INVIARE DI MENO.\n⚠️ NON INVIARE DI PIÙ.\n⚠️ È ACCETTATO SOLO L'IMPORTO ESATTO.\n\n❌ Il pagamento insufficiente è vietato.\n❌ Il pagamento in eccesso è vietato.\n❗ Se invii un importo diverso da {crypto_amount} {crypto_name}, i tuoi fondi saranno persi definitivamente e non potranno essere recuperati.\n\nStato pagamento: {status}\n⏳ Valido fino a: {payment_lifetime}\n\nIndirizzo di deposito:\n{addr}\n\nℹ️ Questo è un indirizzo {crypto_name} univoco creato solo per questo pagamento.\n✅ Il saldo verrà accreditato entro 5 minuti dalla ricezione della transazione.", + "top_up_balance_request_fiat": "💵 Per favore, invia l'importo che desideri ricaricare in {currency_text}\n⚠️ Attenzione! Deposito minimo 5 {currency_text}", + "top_up_balance_invalid_fiat_amount": "⚠️ Importo non valido.\nInvia un importo positivo da 5 fino a meno di 1000000 {currency_text} con al massimo 2 decimali." } } diff --git a/i18n/zh.json b/i18n/zh.json index 8720c443..8d6511be 100644 --- a/i18n/zh.json +++ b/i18n/zh.json @@ -304,7 +304,8 @@ "referral_code_section": "\n\n🔗 您的邀请链接\nt.me/{bot_username}?start={referral_code}\n分享您的链接,共同成长 🚀", "referral_button": "✨ 邀请系统", "top_up_balance_deposit_msg": "💵 请向地址存入您想要的 {crypto_name} 金额,为机器人账户充值。\n\n支付状态: {status}\n支付有效期至 {payment_lifetime}。\n\n重要\n每次存款都会分配一个唯一的 {crypto_name} 地址\n转账后5分钟内完成充值。\n\n余额成功刷新后,您将收到机器人的通知。\n\n您的 {crypto_name} 地址\n{addr}", - "top_up_balance_payment_msg": "💵 请向地址存入 {crypto_amount} {crypto_name},为机器人账户充值 {fiat_amount} {currency_text}\n\n支付状态: {status}\n支付有效期至 {payment_lifetime}。\n\n重要\n每次存款都会分配一个唯一的 {crypto_name} 地址\n转账后5分钟内完成充值。\n\n余额成功刷新后,您将收到机器人的通知。\n\n您的 {crypto_name} 地址\n{addr}", - "top_up_balance_request_fiat": "💵 请输入您希望充值的 {currency_text} 金额\n⚠️ 注意!最低存款金额为 5 {currency_text}" + "top_up_balance_payment_msg": "💵 请准确发送 {crypto_amount} {crypto_name}\n\n⚠️ 不要少发。\n⚠️ 不要多发。\n⚠️ 仅接受准确金额。\n\n❌ 不允许少付。\n❌ 不允许多付。\n❗ 如果您发送的金额不是 {crypto_amount} {crypto_name},您的资金将会永久丢失,并且无法找回。\n\n支付状态: {status}\n⏳ 有效期至: {payment_lifetime}\n\n充值地址:\n{addr}\n\nℹ️ 这是仅为本次支付创建的唯一 {crypto_name} 地址。\n✅ 交易到账后 5 分钟内会为您充值余额。", + "top_up_balance_request_fiat": "💵 请输入您希望充值的 {currency_text} 金额\n⚠️ 注意!最低存款金额为 5 {currency_text}", + "top_up_balance_invalid_fiat_amount": "⚠️ 金额无效。\n请输入一个正数金额,范围从 5 到小于 1000000 {currency_text},最多保留 2 位小数。" } } diff --git a/multibot.py b/multibot.py index 0186d9cd..1069e5bf 100644 --- a/multibot.py +++ b/multibot.py @@ -4,8 +4,6 @@ from aiohttp import web import config from aiogram import Bot, Dispatcher, F, Router -from aiogram.client.session.aiohttp import AiohttpSession -from aiogram.enums import ParseMode from aiogram.exceptions import TelegramUnauthorizedError from aiogram.filters import Command, CommandObject from aiogram.fsm.storage.memory import MemoryStorage @@ -21,6 +19,7 @@ from enums.language import Language from services.multibot import MultibotService from utils.custom_filters import AdminIdFilter +from utils.telegram import create_bot, create_telegram_session from utils.utils import get_text main_router_multibot = Router() @@ -81,9 +80,9 @@ async def on_startup(dispatcher: Dispatcher, bot: Bot): def main(main_router): logging.basicConfig(level=logging.INFO, stream=sys.stdout) - session = AiohttpSession() - bot_settings = {"session": session, "parse_mode": ParseMode.HTML} - bot = Bot(token=MAIN_BOT_TOKEN, **bot_settings) + session = create_telegram_session() + bot_settings = {"session": session} + bot = create_bot(MAIN_BOT_TOKEN, session=session) storage = MemoryStorage() main_dispatcher = Dispatcher(storage=storage) diff --git a/requirements.txt b/requirements.txt index 0a19b98e..7ef1abe5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiogram==3.14.0 +aiohttp-socks==0.10.1 alembic==1.14.0 argon2-cffi==25.1.0 asyncpg==0.31.0 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 0bfd8084..4187f003 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -34,6 +34,7 @@ validate_ltc() { echo "$1" | grep -Eq '^ltc1[a-zA-HJ-NP-Z0-9]{26,}$'; } validate_eth() { echo "$1" | grep -Eq '^0x[a-fA-F0-9]{40}$'; } validate_bnb() { echo "$1" | grep -Eq '^0x[a-fA-F0-9]{40}$'; } validate_sol() { echo "$1" | grep -Eq '^[1-9A-HJ-NP-Za-km-z]{32,44}$'; } +validate_doge() { echo "$1" | grep -Eq '^(D|A|9)[a-km-zA-HJ-NP-Z1-9]{25,34}$'; } read_crypto_address() { COIN="$1" @@ -159,6 +160,9 @@ echo "👉 https://kryptoexpress.pro/profile" printf "KRYPTO_EXPRESS_API_KEY: " read KRYPTO_EXPRESS_API_KEY +printf "TELEGRAM_PROXY_URL (optional, e.g. socks5://host:port): " +read TELEGRAM_PROXY_URL + printf "Enable CRYPTO_FORWARDING_MODE? (true/false): " read CRYPTO_MODE @@ -168,12 +172,14 @@ if [ "$CRYPTO_MODE" = "true" ]; then ETH_ADDR=$(read_crypto_address ETH validate_eth) SOL_ADDR=$(read_crypto_address SOL validate_sol) BNB_ADDR=$(read_crypto_address BNB validate_bnb) + DOGE_ADDR=$(read_crypto_address DOGE validate_doge) else BTC_ADDR="" LTC_ADDR="" ETH_ADDR="" SOL_ADDR="" BNB_ADDR="" + DOGE_ADDR="" fi # ------------------------- @@ -203,6 +209,7 @@ POSTGRES_PASSWORD="$POSTGRES_PASSWORD" DB_PORT="5432" DB_HOST="postgres" POSTGRES_DB="aiogram-shop-bot" +NGROK_TOKEN="" PAGE_ENTRIES="8" MULTIBOT="false" CURRENCY="$CURRENCY" @@ -213,12 +220,22 @@ KRYPTO_EXPRESS_API_URL="https://kryptoexpress.pro/api" KRYPTO_EXPRESS_API_SECRET="$KRYPTO_EXPRESS_API_SECRET" REDIS_PASSWORD="$REDIS_PASSWORD" REDIS_HOST="redis" +TELEGRAM_PROXY_URL="$TELEGRAM_PROXY_URL" CRYPTO_FORWARDING_MODE="$CRYPTO_MODE" BTC_FORWARDING_ADDRESS="$BTC_ADDR" LTC_FORWARDING_ADDRESS="$LTC_ADDR" ETH_FORWARDING_ADDRESS="$ETH_ADDR" SOL_FORWARDING_ADDRESS="$SOL_ADDR" BNB_FORWARDING_ADDRESS="$BNB_ADDR" +DOGE_FORWARDING_ADDRESS="$DOGE_ADDR" +MIN_REFERRER_TOTAL_DEPOSIT="500" +REFERRAL_BONUS_PERCENT="5" +REFERRAL_BONUS_DEPOSIT_LIMIT="3" +REFERRER_BONUS_PERCENT="3" +REFERRER_BONUS_DEPOSIT_LIMIT="5" +REFERRAL_BONUS_CAP_PERCENT="7" +REFERRER_BONUS_CAP_PERCENT="7" +TOTAL_BONUS_CAP_PERCENT="12" SQLADMIN_RAW_PASSWORD="$SQLADMIN_RAW_PASSWORD" JWT_EXPIRE_MINUTES="30" JWT_ALGORITHM="HS256" diff --git a/services/announcement.py b/services/announcement.py index f57c9df8..540345ec 100644 --- a/services/announcement.py +++ b/services/announcement.py @@ -15,12 +15,39 @@ from handlers.admin.constants import AdminConstants from repositories.item import ItemRepository from repositories.user import UserRepository +from services.item import ItemService from services.notification import NotificationService from services.multibot import MultibotService from utils.utils import get_text class AnnouncementService: + @staticmethod + async def _send_generated_announcement_to_user(callback: CallbackQuery, + telegram_id: int, + messages: list[str]) -> tuple[bool, bool]: + if config.MULTIBOT: + had_only_forbidden_errors = False + for message_text in messages: + sent_count, chunk_had_only_forbidden_errors = await MultibotService.send_message_to_user_verbose( + text=message_text, + telegram_id=telegram_id + ) + if sent_count == 0: + return False, chunk_had_only_forbidden_errors + had_only_forbidden_errors = had_only_forbidden_errors or chunk_had_only_forbidden_errors + return True, had_only_forbidden_errors + try: + for message_text in messages: + await callback.bot.send_message(telegram_id, message_text) + return True, False + except TelegramForbiddenError as exception: + logging.error(f"TelegramForbiddenError: {exception.message}") + return False, True + except Exception as exception: + logging.error(exception) + return False, False + @staticmethod async def get_announcement_menu(language: Language) -> tuple[str, InlineKeyboardBuilder]: kb_builder = InlineKeyboardBuilder() @@ -52,16 +79,39 @@ async def send_announcement(callback: CallbackQuery, status=get_text(language, BotEntity.ADMIN, "in_progress") ) ) + generated_messages = None + is_generated_announcement = callback_data.announcement_type in ( + AnnouncementType.RESTOCKING, + AnnouncementType.CURRENT_STOCK + ) + if is_generated_announcement: + generated_messages = await ItemService.create_announcement_message( + callback_data.announcement_type, + session, + language + ) for user in active_users: try: - if config.MULTIBOT: + if is_generated_announcement: + is_sent, had_only_forbidden_errors = await AnnouncementService._send_generated_announcement_to_user( + callback, + user.telegram_id, + generated_messages + ) + if is_sent: + counter += 1 + elif had_only_forbidden_errors: + user.can_receive_messages = False + await UserRepository.update(user, session) + elif config.MULTIBOT: sent_count, had_only_forbidden_errors = await MultibotService.copy_message_to_user( from_chat_id=callback.message.chat.id, message_id=callback.message.message_id, telegram_id=user.telegram_id ) - counter += sent_count - if sent_count == 0 and had_only_forbidden_errors: + if sent_count > 0: + counter += 1 + elif had_only_forbidden_errors: user.can_receive_messages = False await UserRepository.update(user, session) else: @@ -88,9 +138,10 @@ async def send_announcement(callback: CallbackQuery, await NotificationService.edit_message(message=msg, source_message_id=message.message_id, chat_id=message.chat.id) - if callback_data.announcement_type == AnnouncementType.RESTOCKING: - await ItemRepository.set_not_new(session) await session_commit(session) + if callback_data.announcement_type == AnnouncementType.RESTOCKING: + await ItemRepository.set_not_new(session) + await session_commit(session) await NotificationService.edit_message( message=message_template.format( counter=counter, diff --git a/services/buy.py b/services/buy.py index bc5b56b9..6e51fd23 100644 --- a/services/buy.py +++ b/services/buy.py @@ -152,10 +152,7 @@ async def get_purchased_item(callback_data: MyProfileCallback | None, ) if callback_data.user_role == UserRole.ADMIN: user = await UserRepository.get_user_entity(buy_dto.buyer_id, session) - kb_builder.button( - text=get_text(language, BotEntity.COMMON, "user"), - url=f"tg://user?id={user.telegram_id}" - ) + await NotificationService.add_user_button(kb_builder, user, get_text(language, BotEntity.COMMON, "user")) if buy_dto.shipping_address and buy_dto.track_number is None: kb_builder.button( text=get_text(language, BotEntity.ADMIN, "update_track_number"), diff --git a/services/item.py b/services/item.py index 8108f923..dcd90930 100644 --- a/services/item.py +++ b/services/item.py @@ -24,11 +24,54 @@ class ItemService: + ANNOUNCEMENT_MESSAGE_LIMIT = 4000 + + @staticmethod + def _wrap_announcement_chunk(content: str) -> str: + return f"{content}" + + @staticmethod + def _split_category_into_blocks(header: str, + category_header: str, + subcategory_lines: list[str]) -> list[str]: + max_content_length = ItemService.ANNOUNCEMENT_MESSAGE_LIMIT - len("") + category_blocks: list[str] = [] + current_block = category_header + for line in subcategory_lines: + if len(header + current_block + line) <= max_content_length: + current_block += line + continue + if current_block != category_header: + category_blocks.append(current_block) + current_block = category_header + line + else: + category_blocks.append(current_block + line) + current_block = category_header + if current_block != category_header or not category_blocks: + category_blocks.append(current_block) + return category_blocks + + @staticmethod + def _build_announcement_chunks(header: str, category_blocks: list[str]) -> list[str]: + max_content_length = ItemService.ANNOUNCEMENT_MESSAGE_LIMIT - len("") + chunks: list[str] = [] + current_content = header + for block in category_blocks: + if len(current_content + block) <= max_content_length: + current_content += block + continue + if current_content != header: + chunks.append(ItemService._wrap_announcement_chunk(current_content)) + current_content = header + block + if current_content == header and chunks: + return chunks + chunks.append(ItemService._wrap_announcement_chunk(current_content)) + return chunks @staticmethod async def create_announcement_message(announcement_type: AnnouncementType, session: AsyncSession, - language: Language): + language: Language) -> list[str]: if announcement_type == AnnouncementType.CURRENT_STOCK: items = await ItemRepository.get_in_stock(session) header = get_text(language, BotEntity.ADMIN, "current_stock_header") @@ -58,18 +101,22 @@ async def create_announcement_message(announcement_type: AnnouncementType, if subcategory.name not in filtered_items[category.name]: filtered_items[category.name][subcategory.name] = [] filtered_items[category.name][subcategory.name].append(item) - message = header + category_blocks = [] for category, subcategory_item_dict in filtered_items.items(): - message += get_text(language, BotEntity.ADMIN, "restocking_message_category").format( - category=category) + category_header = get_text(language, BotEntity.ADMIN, "restocking_message_category").format( + category=category + ) + subcategory_lines = [] for subcategory, item in subcategory_item_dict.items(): - message += get_text(language, BotEntity.USER, "subcategory_button").format( + subcategory_lines.append(get_text(language, BotEntity.USER, "subcategory_button").format( subcategory_name=subcategory, available_quantity=len(item), subcategory_price=item[0].price, - currency_sym=config.CURRENCY.get_localized_symbol()) + "\n" - message = f"{message}" - return message + currency_sym=config.CURRENCY.get_localized_symbol()) + "\n") + category_blocks.extend( + ItemService._split_category_into_blocks(header, category_header, subcategory_lines) + ) + return ItemService._build_announcement_chunks(header, category_blocks) @staticmethod async def parse_items_json(path_to_file: str, session: AsyncSession | Session): diff --git a/services/multibot.py b/services/multibot.py index 4b81ec89..f8f6e950 100644 --- a/services/multibot.py +++ b/services/multibot.py @@ -2,12 +2,11 @@ import logging from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.exceptions import TelegramForbiddenError, TelegramUnauthorizedError from redis.asyncio import Redis import config +from utils.telegram import create_bot class MultibotService: @@ -31,7 +30,7 @@ async def _close_redis_client(redis_client: Redis, should_close: bool) -> None: @staticmethod def build_bot(token: str) -> Bot: - return Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + return create_bot(token) @staticmethod async def get_child_tokens(redis_client: Redis | None = None) -> list[str]: @@ -100,26 +99,45 @@ async def restore_child_bot_webhooks(webhook_url_template: str, await bot.session.close() @staticmethod - async def send_message_to_user(text: str, - telegram_id: int, - reply_markup=None, - redis_client: Redis | None = None) -> int: + async def send_message_to_user_verbose(text: str, + telegram_id: int, + reply_markup=None, + redis_client: Redis | None = None) -> tuple[int, bool]: success_count = 0 + had_only_forbidden_errors = True tokens = await MultibotService.get_all_tokens_with_main(redis_client) for index, token in enumerate(tokens): bot = MultibotService.build_bot(token) try: await bot.send_message(telegram_id, text, reply_markup=reply_markup) success_count += 1 + had_only_forbidden_errors = False + except TelegramForbiddenError as exception: + logging.error(f"TelegramForbiddenError: {exception.message}") except TelegramUnauthorizedError: logging.warning("Removing unauthorized child bot token during send_message_to_user") await MultibotService.remove_token(token, redis_client) + had_only_forbidden_errors = False except Exception as exception: logging.error(exception) + had_only_forbidden_errors = False finally: await bot.session.close() if index < len(tokens) - 1: await asyncio.sleep(MultibotService.SEND_DELAY_SECONDS) + return success_count, had_only_forbidden_errors + + @staticmethod + async def send_message_to_user(text: str, + telegram_id: int, + reply_markup=None, + redis_client: Redis | None = None) -> int: + success_count, _ = await MultibotService.send_message_to_user_verbose( + text=text, + telegram_id=telegram_id, + reply_markup=reply_markup, + redis_client=redis_client + ) return success_count @staticmethod diff --git a/services/notification.py b/services/notification.py index 5902bf64..dbded922 100644 --- a/services/notification.py +++ b/services/notification.py @@ -2,10 +2,9 @@ import traceback from datetime import datetime, timezone from aiogram import types, Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode +from aiogram.exceptions import TelegramBadRequest from aiogram.types import InlineKeyboardMarkup, BufferedInputFile, Message, InputMediaPhoto, InputMediaVideo, \ - InputMediaAnimation + InputMediaAnimation, InlineKeyboardButton from aiogram.utils.keyboard import InlineKeyboardBuilder from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session @@ -23,6 +22,7 @@ from models.user import UserDTO from models.withdrawal import WithdrawalDTO from services.multibot import MultibotService +from utils.telegram import create_bot from repositories.buyItem import BuyItemRepository from repositories.category import CategoryRepository from repositories.item import ItemRepository @@ -31,25 +31,111 @@ class NotificationService: + PRIVACY_RESTRICTED_PATTERN = "BUTTON_USER_PRIVACY_RESTRICTED" + TG_USER_URL_PREFIX = "tg://user?id=" @staticmethod - async def make_user_button(user_dto: UserDTO) -> InlineKeyboardMarkup: - kb_builder = InlineKeyboardBuilder() + def get_username_link(user_dto: UserDTO) -> str | None: + if user_dto.telegram_username: + return f"https://t.me/{user_dto.telegram_username}" + return None + + @staticmethod + async def get_preferred_user_link(user_dto: UserDTO) -> str | None: + if user_dto.telegram_id is None: + return NotificationService.get_username_link(user_dto) + bot = create_bot(TOKEN) + try: + chat = await bot.get_chat(user_dto.telegram_id) + if chat.has_private_forwards is not True: + return f"{NotificationService.TG_USER_URL_PREFIX}{user_dto.telegram_id}" + except Exception as exception: + logging.warning( + "Could not inspect chat privacy settings for telegram_id=%s: %s", + user_dto.telegram_id, + exception + ) + finally: + await bot.session.close() + return NotificationService.get_username_link(user_dto) + + @staticmethod + async def add_user_button(kb_builder: InlineKeyboardBuilder, user_dto: UserDTO, text: str | None = None) -> bool: + user_link = await NotificationService.get_preferred_user_link(user_dto) + if user_link is None: + return False kb_builder.button( - text=get_text(Language.EN, BotEntity.COMMON, "user"), - url=f"tg://user?id={user_dto.telegram_id}" + text=text or get_text(Language.EN, BotEntity.COMMON, "user"), + url=user_link ) - return kb_builder.as_markup() + return True + + @staticmethod + def _is_privacy_restricted_error(exception: Exception) -> bool: + return isinstance(exception, TelegramBadRequest) and NotificationService.PRIVACY_RESTRICTED_PATTERN in str( + exception + ) + + @staticmethod + def _strip_privacy_restricted_buttons( + reply_markup: InlineKeyboardMarkup | None + ) -> InlineKeyboardMarkup | None: + if reply_markup is None: + return None + filtered_rows: list[list[InlineKeyboardButton]] = [] + for row in reply_markup.inline_keyboard: + filtered_row = [] + for button in row: + if button.url and button.url.startswith(NotificationService.TG_USER_URL_PREFIX): + continue + filtered_row.append(button) + if filtered_row: + filtered_rows.append(filtered_row) + if not filtered_rows: + return None + return InlineKeyboardMarkup(inline_keyboard=filtered_rows) + + @staticmethod + async def _execute_with_privacy_fallback(operation_name: str, + execute, + reply_markup: InlineKeyboardMarkup | None, + chat_id: int | None = None): + try: + return await execute(reply_markup) + except Exception as exception: + if not NotificationService._is_privacy_restricted_error(exception): + raise + sanitized_markup = NotificationService._strip_privacy_restricted_buttons(reply_markup) + logging.warning( + "Privacy-restricted markup fallback triggered during %s for chat_id=%s", + operation_name, + chat_id + ) + return await execute(sanitized_markup) + + @staticmethod + async def make_user_button(user_dto: UserDTO) -> InlineKeyboardMarkup | None: + kb_builder = InlineKeyboardBuilder() + await NotificationService.add_user_button(kb_builder, user_dto) + return kb_builder.as_markup() if list(kb_builder.buttons) else None @staticmethod async def send_to_admins(message: str | BufferedInputFile, reply_markup: types.InlineKeyboardMarkup | None): - bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + bot = create_bot(TOKEN) for admin_id in ADMIN_ID_LIST: try: if isinstance(message, str): - await bot.send_message(admin_id, f"{message}", reply_markup=reply_markup) + async def _send(current_markup): + return await bot.send_message(admin_id, f"{message}", reply_markup=current_markup) else: - await bot.send_document(admin_id, message, reply_markup=reply_markup) + async def _send(current_markup): + return await bot.send_document(admin_id, message, reply_markup=current_markup) + await NotificationService._execute_with_privacy_fallback( + operation_name="send_to_admins", + execute=_send, + reply_markup=reply_markup, + chat_id=admin_id + ) except Exception as e: logging.error(e) await bot.session.close() @@ -59,7 +145,7 @@ async def send_to_user(message: str, telegram_id: int, reply_markup: types.Inlin if config.MULTIBOT: await MultibotService.send_message_to_user(message, telegram_id, reply_markup=reply_markup) return - bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + bot = create_bot(TOKEN) try: await bot.send_message(telegram_id, message, reply_markup=reply_markup) except Exception as e: @@ -69,7 +155,7 @@ async def send_to_user(message: str, telegram_id: int, reply_markup: types.Inlin @staticmethod async def edit_message(message: str, source_message_id: int, chat_id: int): - bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + bot = create_bot(TOKEN) try: await bot.edit_message_text(text=message, chat_id=chat_id, message_id=source_message_id) except Exception as e: @@ -79,7 +165,7 @@ async def edit_message(message: str, source_message_id: int, chat_id: int): @staticmethod async def edit_caption(caption: str, source_message_id: int, chat_id: int): - bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + bot = create_bot(TOKEN) try: await bot.edit_message_caption(caption=caption, chat_id=chat_id, message_id=source_message_id) except Exception as e: @@ -129,13 +215,16 @@ async def new_deposit(payment_dto: ProcessingPaymentDTO, currency_sym=currency_sym, referrer_bonus=referral_bonus_dto.applied_referrer_bonus ) - admin_kb_markup = InlineKeyboardBuilder().from_markup(admin_kb_markup) - admin_kb_markup.button( - text=get_text(Language.EN, BotEntity.COMMON, "referrer"), - url=f"tg://user?id={referral_bonus_dto.referrer_user_dto.telegram_id}" + admin_kb_builder = InlineKeyboardBuilder() + if admin_kb_markup: + admin_kb_builder = InlineKeyboardBuilder().from_markup(admin_kb_markup) + await NotificationService.add_user_button( + admin_kb_builder, + referral_bonus_dto.referrer_user_dto, + text=get_text(Language.EN, BotEntity.COMMON, "referrer") ) - admin_kb_markup.adjust(1) - admin_kb_markup = admin_kb_markup.as_markup() + admin_kb_builder.adjust(1) + admin_kb_markup = admin_kb_builder.as_markup() if list(admin_kb_builder.buttons) else None await NotificationService.send_to_user(referrer_notification_msg, referral_bonus_dto.referrer_user_dto.telegram_id) msg_template = "top_up_balance_payment_msg" if payment_dto.cryptoCurrency in Cryptocurrency.get_stablecoins() else "top_up_balance_deposit_msg" @@ -171,8 +260,10 @@ async def new_deposit(payment_dto: ProcessingPaymentDTO, @staticmethod async def new_buy(buy: BuyDTO, user: UserDTO, session: AsyncSession | Session): - admin_kb_builder = await NotificationService.make_user_button(user) - admin_kb_builder = InlineKeyboardBuilder.from_markup(admin_kb_builder) + admin_kb_markup = await NotificationService.make_user_button(user) + admin_kb_builder = InlineKeyboardBuilder() + if admin_kb_markup: + admin_kb_builder = InlineKeyboardBuilder.from_markup(admin_kb_markup) admin_kb_builder.button( text=get_text(Language.EN, BotEntity.USER, "purchase_history_item").format( buy_id=buy.id, @@ -261,18 +352,26 @@ async def answer_media(message: Message, media: InputMediaPhoto | InputMediaVideo | InputMediaAnimation, reply_markup: InlineKeyboardMarkup | None = None) -> Message: if isinstance(media, InputMediaPhoto): - message = await message.answer_photo(photo=media.media, - caption=media.caption, - reply_markup=reply_markup) + async def _answer(current_markup): + return await message.answer_photo(photo=media.media, + caption=media.caption, + reply_markup=current_markup) elif isinstance(media, InputMediaVideo): - message = await message.answer_video(video=media.media, - caption=media.caption, - reply_markup=reply_markup) + async def _answer(current_markup): + return await message.answer_video(video=media.media, + caption=media.caption, + reply_markup=current_markup) else: - message = await message.answer_animation(animation=media.media, - caption=media.caption, - reply_markup=reply_markup) - return message + async def _answer(current_markup): + return await message.answer_animation(animation=media.media, + caption=media.caption, + reply_markup=current_markup) + return await NotificationService._execute_with_privacy_fallback( + operation_name="answer_media", + execute=_answer, + reply_markup=reply_markup, + chat_id=message.chat.id + ) @staticmethod async def withdrawal(withdraw_dto: WithdrawalDTO): diff --git a/services/payment.py b/services/payment.py index a27f1ab5..64de5dc9 100644 --- a/services/payment.py +++ b/services/payment.py @@ -1,5 +1,6 @@ import io import re +from decimal import Decimal, InvalidOperation from datetime import datetime, timezone import qrcode from aiogram.fsm.context import FSMContext @@ -50,18 +51,34 @@ async def __create_invoice(payment_dto: ProcessingPaymentDTO) -> ProcessingPayme return await CryptoApiWrapper.create_invoice(payment_dto) @staticmethod - def __request_fiat_amount(kb_builder: InlineKeyboardBuilder, language: Language): + def __request_fiat_amount(kb_builder: InlineKeyboardBuilder, language: Language, error_text: str | None = None): kb_builder.button( text=get_text(language, BotEntity.COMMON, "back_button"), callback_data=MyProfileCallback.create(level=1) ) bot_photo_id = get_bot_photo_id() - caption = get_text(language, BotEntity.USER, "top_up_balance_request_fiat").format( + request_caption = get_text(language, BotEntity.USER, "top_up_balance_request_fiat").format( currency_text=config.CURRENCY.get_localized_text() ) + caption = request_caption if error_text is None else f"{error_text}\n\n{request_caption}" media = InputMediaPhoto(media=bot_photo_id, caption=caption) return media, kb_builder + @staticmethod + def _parse_fiat_amount(raw_amount: str | None) -> Decimal | None: + if raw_amount is None: + return None + normalized_amount = raw_amount.strip() + if not PaymentService.AMOUNT_RE.fullmatch(normalized_amount): + return None + try: + parsed_amount = Decimal(normalized_amount) + except InvalidOperation: + return None + if parsed_amount < Decimal("5") or parsed_amount >= Decimal("1000000"): + return None + return parsed_amount + @staticmethod async def create(callback: CallbackQuery | Message, callback_data: MyProfileCallback | None, @@ -72,11 +89,21 @@ async def create(callback: CallbackQuery | Message, unexpired_payments_count = await PaymentRepository.get_unexpired_unpaid_payments(user.id, session) state_data = await state.get_data() current_state = await state.get_state() + kb_builder = InlineKeyboardBuilder() if callback_data is None: - cryptocurrency = Cryptocurrency(state_data.get('cryptocurrency')) + stored_cryptocurrency = state_data.get('cryptocurrency') + if stored_cryptocurrency is None: + await state.set_state(UserStates.top_up_amount) + return PaymentService.__request_fiat_amount( + kb_builder, + language, + get_text(language, BotEntity.USER, "top_up_balance_invalid_fiat_amount").format( + currency_text=config.CURRENCY.get_localized_text() + ) + ) + cryptocurrency = Cryptocurrency(stored_cryptocurrency) else: cryptocurrency = callback_data.cryptocurrency - kb_builder = InlineKeyboardBuilder() if unexpired_payments_count >= 15: kb_builder.row(callback_data.get_back_button(language)) return get_text(language, BotEntity.USER, "too_many_payment_request"), kb_builder @@ -86,40 +113,41 @@ async def create(callback: CallbackQuery | Message, return PaymentService.__request_fiat_amount(kb_builder, language) elif cryptocurrency in Cryptocurrency.get_stablecoins() and current_state == UserStates.top_up_amount: message: Message = callback - fiat_amount = message.html_text - if PaymentService.AMOUNT_RE.fullmatch(fiat_amount): - fiat_amount = float(fiat_amount) - if 5 <= fiat_amount < 1_000_000: - await state.set_state() - payment_dto = ProcessingPaymentDTO( - paymentType=PaymentType.PAYMENT, - fiatCurrency=config.CURRENCY, - cryptoCurrency=cryptocurrency, - fiatAmount=fiat_amount - ) - message = await message.answer(text=get_text(language, BotEntity.USER, "loading")) - await state.update_data(msg_id=message.message_id, chat_id=message.chat.id) - payment_dto = await PaymentService.__create_invoice(payment_dto) - await PaymentRepository.create(payment_dto.id, user.id, message.message_id, session) - await session_commit(session) - timestamp_s = payment_dto.expireDatetime / 1000 - dt = datetime.fromtimestamp(timestamp_s, tz=timezone.utc) - formatted = dt.strftime('%H:%M UTC on %B %d, %Y') - caption = get_text(language, BotEntity.USER, "top_up_balance_payment_msg").format( - crypto_name=payment_dto.cryptoCurrency.name, - addr=payment_dto.address, - crypto_amount=payment_dto.cryptoAmount, - fiat_amount=payment_dto.fiatAmount, - currency_text=config.CURRENCY.get_localized_text(), - status=get_text(language, BotEntity.USER, "status_pending"), - payment_lifetime=formatted + fiat_amount = PaymentService._parse_fiat_amount(message.text or message.html_text) + if fiat_amount is None: + return PaymentService.__request_fiat_amount( + kb_builder, + language, + get_text(language, BotEntity.USER, "top_up_balance_invalid_fiat_amount").format( + currency_text=config.CURRENCY.get_localized_text() ) - qr_code_file = PaymentService.__create_qr_code(payment_dto) - return InputMediaPhoto(media=qr_code_file, caption=caption), kb_builder - else: - return PaymentService.__request_fiat_amount(kb_builder, language) - else: - return PaymentService.__request_fiat_amount(kb_builder, language) + ) + await state.set_state() + payment_dto = ProcessingPaymentDTO( + paymentType=PaymentType.PAYMENT, + fiatCurrency=config.CURRENCY, + cryptoCurrency=cryptocurrency, + fiatAmount=float(fiat_amount) + ) + message = await message.answer(text=get_text(language, BotEntity.USER, "loading")) + await state.update_data(msg_id=message.message_id, chat_id=message.chat.id) + payment_dto = await PaymentService.__create_invoice(payment_dto) + await PaymentRepository.create(payment_dto.id, user.id, message.message_id, session) + await session_commit(session) + timestamp_s = payment_dto.expireDatetime / 1000 + dt = datetime.fromtimestamp(timestamp_s, tz=timezone.utc) + formatted = dt.strftime('%H:%M UTC on %B %d, %Y') + caption = get_text(language, BotEntity.USER, "top_up_balance_payment_msg").format( + crypto_name=payment_dto.cryptoCurrency.name, + addr=payment_dto.address, + crypto_amount=payment_dto.cryptoAmount, + fiat_amount=payment_dto.fiatAmount, + currency_text=config.CURRENCY.get_localized_text(), + status=get_text(language, BotEntity.USER, "status_pending"), + payment_lifetime=formatted + ) + qr_code_file = PaymentService.__create_qr_code(payment_dto) + return InputMediaPhoto(media=qr_code_file, caption=caption), kb_builder else: message = await callback.message.edit_caption(caption=get_text(language, BotEntity.USER, "loading")) payment_dto = ProcessingPaymentDTO( diff --git a/services/review.py b/services/review.py index 8196627a..3ff19948 100644 --- a/services/review.py +++ b/services/review.py @@ -197,7 +197,7 @@ async def review_confirmation(callback_data: ReviewManagementCallback, buyItem_dto = await BuyItemRepository.get_by_id(callback_data.buyItem_id, session) item_dto = await ItemRepository.get_by_id(buyItem_dto.item_ids[0], session) category = await CategoryRepository.get_by_id(item_dto.category_id, session) - subcategory = await SubcategoryRepository.get_by_id(item_dto.category_id, session) + subcategory = await SubcategoryRepository.get_by_id(item_dto.subcategory_id, session) kb_builder = InlineKeyboardBuilder() kb_builder.button( text=get_text(language, BotEntity.COMMON, "confirm"), @@ -232,7 +232,7 @@ async def create_review(callback_data: ReviewManagementCallback, language: Language) -> tuple[InputMediaPhoto, InlineKeyboardBuilder]: state_data = await state.get_data() await state.clear() - review_dto = await ReviewRepository.get_by_buy_item_id(callback_data.review_id, session) + review_dto = await ReviewRepository.get_by_buy_item_id(callback_data.buyItem_id, session) if review_dto is None: review_dto = ReviewDTO( buyItem_id=callback_data.buyItem_id, @@ -328,10 +328,7 @@ async def view_review_single(callback_data: ReviewManagementCallback, ) buy_dto = await BuyRepository.get_by_id(buyItem_dto.buy_id, session) user_dto = await UserRepository.get_user_entity(buy_dto.buyer_id, session) - kb_builder.button( - text=get_text(language, BotEntity.COMMON, "user"), - url=f"tg://user?id={user_dto.telegram_id}" - ) + await NotificationService.add_user_button(kb_builder, user_dto, get_text(language, BotEntity.COMMON, "user")) kb_builder.adjust(1) back_button = InlineKeyboardButton( text=get_text(language, BotEntity.COMMON, "back_button"), diff --git a/services/statistics.py b/services/statistics.py index 9f059994..719c4f58 100644 --- a/services/statistics.py +++ b/services/statistics.py @@ -21,6 +21,7 @@ from repositories.buyItem import BuyItemRepository from repositories.deposit import DepositRepository from repositories.user import UserRepository +from services.notification import NotificationService from utils.utils import get_text import matplotlib.pyplot as plt import matplotlib.dates as mdates @@ -146,9 +147,13 @@ async def get_statistics(callback_data: StatisticsCallback, callback_data.page, session) users_qty = await UserRepository.get_qty_by_timedelta(callback_data.timedelta, session) - [kb_builder.button(text=user.telegram_username, url=f'tg://user?id={user.telegram_id}') for user in - users_paginated - if user.telegram_username] + for user in users_paginated: + if user.telegram_username or user.telegram_id: + await NotificationService.add_user_button( + kb_builder, + user, + text=f"@{user.telegram_username}" if user.telegram_username else str(user.telegram_id) + ) state_data = await state.get_data() chart = state_data.get(f"chart_{callback_data.timedelta.name.lower()}") if chart is None: diff --git a/services/user_management.py b/services/user_management.py index 4b650282..536a036c 100644 --- a/services/user_management.py +++ b/services/user_management.py @@ -195,10 +195,7 @@ async def get_user(message: Message | UserManagementCallback, operation=UserManagementOperation.BAN, user_id=user.id) ) - kb_builder.button( - text=get_text(language, BotEntity.COMMON, "user"), - url=f"tg://user?id={user.telegram_id}" - ) + await NotificationService.add_user_button(kb_builder, user, get_text(language, BotEntity.COMMON, "user")) total_purchases_qty = await BuyRepository.get_qty_by_buyer_id(user.id, session) total_spent_amount = await BuyRepository.get_spent_amount(user.id, session) msg = get_text(language, BotEntity.ADMIN, "user_info").format( diff --git a/services/wallet.py b/services/wallet.py index 280053d9..2f6a5f46 100644 --- a/services/wallet.py +++ b/services/wallet.py @@ -125,6 +125,8 @@ async def withdraw_transaction(callback_data: WalletCallback, @staticmethod def validate_withdrawal_address(address: str, cryptocurrency: Cryptocurrency) -> bool: + if not address: + return False address_regex = { Cryptocurrency.BTC: re.compile(r'^bc1[a-zA-HJ-NP-Z0-9]{25,39}$'), Cryptocurrency.LTC: re.compile(r'^ltc1[a-zA-HJ-NP-Z0-9]{26,}$'), diff --git a/tests/conftest.py b/tests/conftest.py index ead0d770..b70a18ff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,12 +32,14 @@ def _build_config_module() -> ModuleType: config.CRYPTO_FORWARDING_MODE = False config.BTC_FORWARDING_ADDRESS = "btc-forward" config.LTC_FORWARDING_ADDRESS = "ltc-forward" + config.DOGE_FORWARDING_ADDRESS = "doge-forward" config.ETH_FORWARDING_ADDRESS = "eth-forward" config.SOL_FORWARDING_ADDRESS = "sol-forward" config.BNB_FORWARDING_ADDRESS = "bnb-forward" config.ADMIN_ID_LIST = [1] config.TOKEN = "token" config.MULTIBOT = False + config.TELEGRAM_PROXY_URL = None config.REDIS_HOST = "localhost" config.REDIS_PASSWORD = "password" config.CURRENCY = currency diff --git a/tests/test_announcement_handler.py b/tests/test_announcement_handler.py new file mode 100644 index 00000000..52de0a91 --- /dev/null +++ b/tests/test_announcement_handler.py @@ -0,0 +1,47 @@ +from types import SimpleNamespace + +import pytest + +from callbacks import AnnouncementCallback +from enums.announcement_type import AnnouncementType +from enums.language import Language +from handlers.admin.announcement import send_generated_msg + + +class _FakeMessage: + def __init__(self): + self.answers = [] + + async def answer(self, text, reply_markup=None): + self.answers.append((text, reply_markup)) + return SimpleNamespace() + + +class _FakeCallback: + def __init__(self): + self.message = _FakeMessage() + + +@pytest.mark.asyncio +async def test_send_generated_msg_sends_first_chunk_with_keyboard_only(monkeypatch): + callback = _FakeCallback() + + async def _fake_create_announcement_message(*args, **kwargs): + return ["chunk-1", "chunk-2", "chunk-3"] + + monkeypatch.setattr( + "handlers.admin.announcement.ItemService.create_announcement_message", + _fake_create_announcement_message + ) + + await send_generated_msg( + callback=callback, + session=None, + callback_data=AnnouncementCallback.create(2, AnnouncementType.CURRENT_STOCK), + language=Language.EN + ) + + assert [answer[0] for answer in callback.message.answers] == ["chunk-1", "chunk-2", "chunk-3"] + assert callback.message.answers[0][1] is not None + assert callback.message.answers[1][1] is None + assert callback.message.answers[2][1] is None diff --git a/tests/test_item_service.py b/tests/test_item_service.py new file mode 100644 index 00000000..c7339adc --- /dev/null +++ b/tests/test_item_service.py @@ -0,0 +1,95 @@ +from types import SimpleNamespace + +import pytest + +from enums.announcement_type import AnnouncementType +from enums.language import Language +from services.item import ItemService + + +def _build_item(category_id: int, subcategory_id: int, price: float = 10.0): + return SimpleNamespace(category_id=category_id, subcategory_id=subcategory_id, price=price) + + +@pytest.mark.asyncio +async def test_create_announcement_message_returns_single_chunk_for_short_content(monkeypatch): + async def _fake_get_items(session): + return [ + _build_item(1, 10), + _build_item(1, 11), + ] + + async def _fake_get_categories(ids, session): + return [SimpleNamespace(id=1, name="Category 1")] + + async def _fake_get_subcategories(ids, session): + return [ + SimpleNamespace(id=10, name="Sub 1"), + SimpleNamespace(id=11, name="Sub 2"), + ] + + monkeypatch.setattr("services.item.ItemRepository.get_in_stock", _fake_get_items) + monkeypatch.setattr("services.item.CategoryRepository.get_by_ids", _fake_get_categories) + monkeypatch.setattr("services.item.SubcategoryRepository.get_by_ids", _fake_get_subcategories) + + messages = await ItemService.create_announcement_message( + AnnouncementType.CURRENT_STOCK, + session=None, + language=Language.EN + ) + + assert len(messages) == 1 + assert messages[0].startswith("") + assert "Category 1" in messages[0] + + +@pytest.mark.asyncio +async def test_create_announcement_message_splits_long_content_by_category(monkeypatch): + async def _fake_get_items(session): + return [_build_item(category_id=index, subcategory_id=index) for index in range(1, 60)] + + async def _fake_get_categories(ids, session): + return [SimpleNamespace(id=index, name=f"Category {index} {'X' * 80}") for index in ids] + + async def _fake_get_subcategories(ids, session): + return [SimpleNamespace(id=index, name=f"Subcategory {index} {'Y' * 60}") for index in ids] + + monkeypatch.setattr("services.item.ItemRepository.get_in_stock", _fake_get_items) + monkeypatch.setattr("services.item.CategoryRepository.get_by_ids", _fake_get_categories) + monkeypatch.setattr("services.item.SubcategoryRepository.get_by_ids", _fake_get_subcategories) + + messages = await ItemService.create_announcement_message( + AnnouncementType.CURRENT_STOCK, + session=None, + language=Language.EN + ) + + assert len(messages) > 1 + assert all(len(message) < ItemService.ANNOUNCEMENT_MESSAGE_LIMIT for message in messages) + assert "Category 1" in messages[0] + + +@pytest.mark.asyncio +async def test_create_announcement_message_splits_large_single_category_without_losing_header(monkeypatch): + async def _fake_get_items(session): + return [_build_item(category_id=1, subcategory_id=index) for index in range(1, 80)] + + async def _fake_get_categories(ids, session): + return [SimpleNamespace(id=1, name="Single Category")] + + async def _fake_get_subcategories(ids, session): + return [SimpleNamespace(id=index, name=f"Subcategory {index} {'Z' * 70}") for index in ids] + + monkeypatch.setattr("services.item.ItemRepository.get_new", _fake_get_items) + monkeypatch.setattr("services.item.CategoryRepository.get_by_ids", _fake_get_categories) + monkeypatch.setattr("services.item.SubcategoryRepository.get_by_ids", _fake_get_subcategories) + + messages = await ItemService.create_announcement_message( + AnnouncementType.RESTOCKING, + session=None, + language=Language.EN + ) + + assert len(messages) > 1 + assert all("Single Category" in message for message in messages) + assert all(len(message) < ItemService.ANNOUNCEMENT_MESSAGE_LIMIT for message in messages) diff --git a/tests/test_multibot.py b/tests/test_multibot.py index 745d22a1..899f7dd0 100644 --- a/tests/test_multibot.py +++ b/tests/test_multibot.py @@ -97,6 +97,7 @@ async def edit_reply_markup(self): class _FakeCallback: def __init__(self): self.message = _FakeMessage() + self.bot = _FakeBot("main-token") @pytest.mark.asyncio @@ -261,7 +262,10 @@ async def _fake_get_active(session): async def _fake_get_all_count(session): return 1 - async def _fake_copy_message(from_chat_id, message_id, telegram_id): + async def _fake_create_announcement_message(announcement_type, session, language): + return ["chunk-1", "chunk-2"] + + async def _fake_send_message_to_user_verbose(text, telegram_id, reply_markup=None, redis_client=None): return 2, False async def _fake_edit_message(message, source_message_id, chat_id): @@ -272,7 +276,9 @@ async def _fake_session_commit(session): monkeypatch.setattr("services.announcement.UserRepository.get_active", _fake_get_active) monkeypatch.setattr("services.announcement.UserRepository.get_all_count", _fake_get_all_count) - monkeypatch.setattr("services.announcement.MultibotService.copy_message_to_user", _fake_copy_message) + monkeypatch.setattr("services.announcement.ItemService.create_announcement_message", _fake_create_announcement_message) + monkeypatch.setattr("services.announcement.MultibotService.send_message_to_user_verbose", + _fake_send_message_to_user_verbose) monkeypatch.setattr("services.announcement.NotificationService.edit_message", _fake_edit_message) monkeypatch.setattr("services.announcement.session_commit", _fake_session_commit) @@ -283,7 +289,7 @@ async def _fake_session_commit(session): Language.EN ) - assert any("2" in message for message in updates) + assert any("1" in message for message in updates) @pytest.mark.asyncio @@ -300,7 +306,10 @@ async def _fake_get_active(session): async def _fake_get_all_count(session): return 1 - async def _fake_copy_message(from_chat_id, message_id, telegram_id): + async def _fake_create_announcement_message(announcement_type, session, language): + return ["chunk-1", "chunk-2"] + + async def _fake_send_message_to_user_verbose(text, telegram_id, reply_markup=None, redis_client=None): return 0, True async def _fake_update(user_dto, session): @@ -314,7 +323,9 @@ async def _fake_session_commit(session): monkeypatch.setattr("services.announcement.UserRepository.get_active", _fake_get_active) monkeypatch.setattr("services.announcement.UserRepository.get_all_count", _fake_get_all_count) - monkeypatch.setattr("services.announcement.MultibotService.copy_message_to_user", _fake_copy_message) + monkeypatch.setattr("services.announcement.ItemService.create_announcement_message", _fake_create_announcement_message) + monkeypatch.setattr("services.announcement.MultibotService.send_message_to_user_verbose", + _fake_send_message_to_user_verbose) monkeypatch.setattr("services.announcement.UserRepository.update", _fake_update) monkeypatch.setattr("services.announcement.NotificationService.edit_message", _fake_edit_message) monkeypatch.setattr("services.announcement.session_commit", _fake_session_commit) @@ -330,6 +341,54 @@ async def _fake_session_commit(session): assert updated_users[0].can_receive_messages is False +@pytest.mark.asyncio +async def test_announcement_service_marks_restocking_not_new_once(monkeypatch): + callback = _FakeCallback() + set_not_new_calls = [] + user = UserDTO(telegram_id=777, can_receive_messages=True) + + monkeypatch.setattr("config.MULTIBOT", True) + + async def _fake_get_active(session): + return [user, user] + + async def _fake_get_all_count(session): + return 2 + + async def _fake_create_announcement_message(announcement_type, session, language): + return ["chunk-1"] + + async def _fake_send_message_to_user_verbose(text, telegram_id, reply_markup=None, redis_client=None): + return 1, False + + async def _fake_set_not_new(session): + set_not_new_calls.append(True) + + async def _fake_edit_message(message, source_message_id, chat_id): + return None + + async def _fake_session_commit(session): + return None + + monkeypatch.setattr("services.announcement.UserRepository.get_active", _fake_get_active) + monkeypatch.setattr("services.announcement.UserRepository.get_all_count", _fake_get_all_count) + monkeypatch.setattr("services.announcement.ItemService.create_announcement_message", _fake_create_announcement_message) + monkeypatch.setattr("services.announcement.MultibotService.send_message_to_user_verbose", + _fake_send_message_to_user_verbose) + monkeypatch.setattr("services.announcement.ItemRepository.set_not_new", _fake_set_not_new) + monkeypatch.setattr("services.announcement.NotificationService.edit_message", _fake_edit_message) + monkeypatch.setattr("services.announcement.session_commit", _fake_session_commit) + + await AnnouncementService.send_announcement( + callback, + SimpleNamespace(announcement_type=AnnouncementType.RESTOCKING), + object(), + Language.EN + ) + + assert len(set_not_new_calls) == 1 + + @pytest.mark.asyncio async def test_multibot_startup_restores_child_webhooks(monkeypatch): restored = [] diff --git a/tests/test_notification_service.py b/tests/test_notification_service.py new file mode 100644 index 00000000..5df1a3ac --- /dev/null +++ b/tests/test_notification_service.py @@ -0,0 +1,120 @@ +import pytest +from aiogram.exceptions import TelegramBadRequest +from aiogram.methods import SendMessage +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from types import SimpleNamespace + +from services.notification import NotificationService + + +def _privacy_error() -> TelegramBadRequest: + return TelegramBadRequest( + method=SendMessage(chat_id=1, text="test"), + message="Telegram server says - Bad Request: BUTTON_USER_PRIVACY_RESTRICTED" + ) + + +def test_strip_privacy_restricted_buttons_keeps_safe_buttons(): + markup = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="User", url="tg://user?id=123"), + InlineKeyboardButton(text="Explorer", url="https://example.com/tx/1"), + InlineKeyboardButton(text="Callback", callback_data="keep-me"), + ]]) + + sanitized = NotificationService._strip_privacy_restricted_buttons(markup) + + assert sanitized is not None + assert len(sanitized.inline_keyboard[0]) == 2 + assert sanitized.inline_keyboard[0][0].text == "Explorer" + assert sanitized.inline_keyboard[0][1].callback_data == "keep-me" + + +@pytest.mark.asyncio +async def test_privacy_fallback_retries_once_with_sanitized_markup(): + markup = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="User", url="tg://user?id=123"), + InlineKeyboardButton(text="Safe", callback_data="ok"), + ]]) + calls = [] + + async def _execute(current_markup): + calls.append(current_markup) + if len(calls) == 1: + raise _privacy_error() + return "ok" + + result = await NotificationService._execute_with_privacy_fallback( + operation_name="test", + execute=_execute, + reply_markup=markup, + chat_id=42 + ) + + assert result == "ok" + assert len(calls) == 2 + assert calls[1] is not None + assert len(calls[1].inline_keyboard[0]) == 1 + assert calls[1].inline_keyboard[0][0].callback_data == "ok" + + +@pytest.mark.asyncio +async def test_non_privacy_errors_are_not_swallowed(): + markup = InlineKeyboardMarkup(inline_keyboard=[[ + InlineKeyboardButton(text="Safe", callback_data="ok"), + ]]) + + async def _execute(current_markup): + raise TelegramBadRequest( + method=SendMessage(chat_id=1, text="test"), + message="Telegram server says - Bad Request: CHAT_NOT_FOUND" + ) + + with pytest.raises(TelegramBadRequest): + await NotificationService._execute_with_privacy_fallback( + operation_name="test", + execute=_execute, + reply_markup=markup, + chat_id=42 + ) + + +@pytest.mark.asyncio +async def test_preferred_user_link_uses_tgid_when_private_forwards_allowed(monkeypatch): + class _Bot: + def __init__(self, *args, **kwargs): + self.session = SimpleNamespace(close=self._close) + + async def get_chat(self, user_id): + return SimpleNamespace(has_private_forwards=False) + + async def _close(self): + return None + + monkeypatch.setattr("services.notification.create_bot", lambda token: _Bot()) + + link = await NotificationService.get_preferred_user_link( + SimpleNamespace(telegram_id=123, telegram_username="demo") + ) + + assert link == "tg://user?id=123" + + +@pytest.mark.asyncio +async def test_preferred_user_link_falls_back_to_username(monkeypatch): + class _Bot: + def __init__(self, *args, **kwargs): + self.session = SimpleNamespace(close=self._close) + + async def get_chat(self, user_id): + return SimpleNamespace(has_private_forwards=True) + + async def _close(self): + return None + + monkeypatch.setattr("services.notification.create_bot", lambda token: _Bot()) + + link = await NotificationService.get_preferred_user_link( + SimpleNamespace(telegram_id=123, telegram_username="demo") + ) + + assert link == "https://t.me/demo" diff --git a/tests/test_payment_service.py b/tests/test_payment_service.py new file mode 100644 index 00000000..6b716f9d --- /dev/null +++ b/tests/test_payment_service.py @@ -0,0 +1,110 @@ +from decimal import Decimal +from types import SimpleNamespace + +import pytest + +from enums.bot_entity import BotEntity +from enums.cryptocurrency import Cryptocurrency +from enums.language import Language +from handlers.user.constants import UserStates +from handlers.user.my_profile import receive_top_up_amount +from services.payment import PaymentService +from utils.utils import get_text + + +class _State: + def __init__(self, data=None): + self._data = data or {} + + async def get_data(self): + return dict(self._data) + + async def set_state(self, value=None): + self._data["state"] = value + + async def update_data(self, **kwargs): + self._data.update(kwargs) + + async def get_state(self): + return self._data.get("state") + + +class _Bot: + def __init__(self): + self.edits = [] + + async def edit_message_media(self, **kwargs): + self.edits.append(kwargs) + + +class _Message: + def __init__(self, text: str): + self.text = text + self.html_text = text + self.bot = _Bot() + self.from_user = SimpleNamespace(id=1) + + +@pytest.mark.parametrize( + ("raw_amount", "expected"), + [ + ("10", Decimal("10")), + (" 10.50 ", Decimal("10.50")), + ("-1", None), + ("0", None), + ("1.234", None), + ("1e3", None), + ("abc", None), + ("", None), + ], +) +def test_parse_fiat_amount_handles_invalid_values(raw_amount, expected): + assert PaymentService._parse_fiat_amount(raw_amount) == expected + + +@pytest.mark.asyncio +async def test_receive_top_up_amount_answers_media_when_no_placeholder_message(monkeypatch): + message = _Message("-10") + state = _State() + called = {} + + async def _fake_create(*args, **kwargs): + return "media-object", SimpleNamespace(as_markup=lambda: "markup") + + async def _fake_answer_media(current_message, media, reply_markup): + called["message"] = current_message + called["media"] = media + called["reply_markup"] = reply_markup + + monkeypatch.setattr("handlers.user.my_profile.PaymentService.create", _fake_create) + monkeypatch.setattr("handlers.user.my_profile.NotificationService.answer_media", _fake_answer_media) + + await receive_top_up_amount(message, state, session=None, language=Language.EN) + + assert called["message"] is message + assert called["media"] == "media-object" + assert called["reply_markup"] == "markup" + assert message.bot.edits == [] + + +@pytest.mark.asyncio +async def test_stablecoin_invalid_amount_returns_localized_retry_screen(monkeypatch): + state = _State({ + "cryptocurrency": Cryptocurrency.USDT_ERC20.value, + "state": UserStates.top_up_amount + }) + message = _Message("-10") + + async def _fake_get_by_tgid(*args, **kwargs): + return SimpleNamespace(id=1) + + async def _fake_unexpired(*args, **kwargs): + return 0 + + monkeypatch.setattr("services.payment.UserRepository.get_by_tgid", _fake_get_by_tgid) + monkeypatch.setattr("services.payment.PaymentRepository.get_unexpired_unpaid_payments", _fake_unexpired) + monkeypatch.setattr("services.payment.get_bot_photo_id", lambda: "bot-photo-id") + + media, _ = await PaymentService.create(message, None, state, session=None, language=Language.EN) + + assert get_text(Language.EN, BotEntity.USER, "top_up_balance_invalid_fiat_amount").split("\n")[0] in media.caption diff --git a/tests/test_review_service.py b/tests/test_review_service.py new file mode 100644 index 00000000..647adc1a --- /dev/null +++ b/tests/test_review_service.py @@ -0,0 +1,85 @@ +from types import SimpleNamespace + +import pytest + +from callbacks import ReviewManagementCallback +from enums.language import Language +from services.review import ReviewService + + +class _State: + def __init__(self, data): + self._data = data + self.cleared = False + + async def get_data(self): + return dict(self._data) + + async def clear(self): + self.cleared = True + + +class _Session: + def __init__(self): + self.commits = 0 + + async def commit(self): + self.commits += 1 + + +@pytest.mark.asyncio +async def test_review_confirmation_uses_subcategory_id(monkeypatch): + callback_data = ReviewManagementCallback.create(level=4, buy_id=1, buyItem_id=2, rating=5) + requested_subcategory_ids = [] + + async def _fake_get_buy_item(*args, **kwargs): + return SimpleNamespace(item_ids=[9]) + + async def _fake_get_item(*args, **kwargs): + return SimpleNamespace(category_id=11, subcategory_id=77, price=15.0, item_type=SimpleNamespace( + get_localized=lambda language: "Digital" + )) + + async def _fake_get_category(*args, **kwargs): + return SimpleNamespace(name="Category") + + async def _fake_get_subcategory(subcategory_id, *args, **kwargs): + requested_subcategory_ids.append(subcategory_id) + return SimpleNamespace(name="Subcategory") + + monkeypatch.setattr("services.review.BuyItemRepository.get_by_id", _fake_get_buy_item) + monkeypatch.setattr("services.review.ItemRepository.get_by_id", _fake_get_item) + monkeypatch.setattr("services.review.CategoryRepository.get_by_id", _fake_get_category) + monkeypatch.setattr("services.review.SubcategoryRepository.get_by_id", _fake_get_subcategory) + monkeypatch.setattr("services.review.get_bot_photo_id", lambda: "bot-photo-id") + + await ReviewService.review_confirmation(callback_data, _State({"review_text": "ok"}), session=None, language=Language.EN) + + assert requested_subcategory_ids == [77] + + +@pytest.mark.asyncio +async def test_create_review_checks_duplicate_by_buy_item_id(monkeypatch): + callback_data = ReviewManagementCallback.create(level=4, buy_id=1, buyItem_id=22, review_id=999, rating=5) + requested_ids = [] + + async def _fake_get_by_buy_item_id(buy_item_id, *args, **kwargs): + requested_ids.append(buy_item_id) + return SimpleNamespace(id=1, buyItem_id=buy_item_id) + + async def _fake_new_review_published(*args, **kwargs): + return None + + monkeypatch.setattr("services.review.ReviewRepository.get_by_buy_item_id", _fake_get_by_buy_item_id) + monkeypatch.setattr("services.review.NotificationService.new_review_published", _fake_new_review_published) + monkeypatch.setattr("services.review.get_bot_photo_id", lambda: "bot-photo-id") + + media, _ = await ReviewService.create_review( + callback_data, + _State({"review_text": "ok"}), + session=_Session(), + language=Language.EN + ) + + assert requested_ids == [22] + assert media.caption diff --git a/tests/test_telegram_utils.py b/tests/test_telegram_utils.py new file mode 100644 index 00000000..2c1df25f --- /dev/null +++ b/tests/test_telegram_utils.py @@ -0,0 +1,40 @@ +from types import SimpleNamespace + +import pytest + +from utils.telegram import create_telegram_session, create_bot + + +def test_create_telegram_session_without_proxy(monkeypatch): + monkeypatch.setattr("config.TELEGRAM_PROXY_URL", None) + + session = create_telegram_session() + + assert session._proxy is None + + +def test_create_telegram_session_with_proxy(monkeypatch): + monkeypatch.setattr("config.TELEGRAM_PROXY_URL", "socks5://192.168.1.1:10808") + + session = create_telegram_session() + + assert session._proxy == "socks5://192.168.1.1:10808" + + +def test_create_bot_uses_proxy_session_by_default(monkeypatch): + fake_session = SimpleNamespace() + captured = {} + + class _FakeBot: + def __init__(self, token, session, default): + captured["token"] = token + captured["session"] = session + captured["default"] = default + + monkeypatch.setattr("utils.telegram.create_telegram_session", lambda: fake_session) + monkeypatch.setattr("utils.telegram.Bot", _FakeBot) + + create_bot("123:ABC") + + assert captured["token"] == "123:ABC" + assert captured["session"] is fake_session diff --git a/tests/test_wallet_service.py b/tests/test_wallet_service.py index e4103847..c08a2292 100644 --- a/tests/test_wallet_service.py +++ b/tests/test_wallet_service.py @@ -43,6 +43,11 @@ def test_wallet_validates_new_currency_addresses(): assert WalletService.validate_withdrawal_address("D8BFXqDM7MHf3A4j3kC8wWEN8DqRLVQjax", Cryptocurrency.DOGE) +def test_wallet_rejects_empty_address_without_exception(): + assert WalletService.validate_withdrawal_address(None, Cryptocurrency.BTC) is False + assert WalletService.validate_withdrawal_address("", Cryptocurrency.BTC) is False + + @pytest.mark.asyncio async def test_top_up_buttons_include_supported_currencies(): _, keyboard = await UserService.get_top_up_buttons( diff --git a/utils/telegram.py b/utils/telegram.py new file mode 100644 index 00000000..e4753f36 --- /dev/null +++ b/utils/telegram.py @@ -0,0 +1,21 @@ +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.enums import ParseMode + +import config + + +def create_telegram_session() -> AiohttpSession: + if config.TELEGRAM_PROXY_URL: + return AiohttpSession(proxy=config.TELEGRAM_PROXY_URL) + return AiohttpSession() + + +def create_bot(token: str, session: AiohttpSession | None = None) -> Bot: + session = session or create_telegram_session() + return Bot( + token=token, + session=session, + default=DefaultBotProperties(parse_mode=ParseMode.HTML) + )