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)
+ )