From 858d201cef9706d6263e786f8f8be261c1806999 Mon Sep 17 00:00:00 2001 From: ilyarolf Date: Tue, 17 Mar 2026 02:43:18 +0300 Subject: [PATCH 1/6] added CODE_OF_CONDUCT.md, CONTRIBUTING.md, PULL_REQUEST_TEMPLATE.md, SECURITY.md --- .github/PULL_REQUEST_TEMPLATE.md | 26 +++++++ CODE_OF_CONDUCT.md | 35 ++++++++++ CONTRIBUTING.md | 113 +++++++++++++++++++++++++++++++ SECURITY.md | 58 ++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..232b50bd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,26 @@ +## Summary + +Describe the change in 1-3 sentences. + +## Why this change? + +Explain the problem this PR solves. + +## What changed? + +- +- +- + +## Testing + +Describe how this was tested. + +## Documentation + +- [ ] Documentation updated if needed +- [ ] Screenshots added if UI/admin flow changed + +## Notes + +Anything reviewers should pay attention to. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d96602f9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,35 @@ +# Code of Conduct + +## Our standard + +We want this project to be welcoming, constructive, and respectful. + +Examples of positive behavior include: + +- being respectful in discussions +- giving actionable and specific feedback +- assuming good intent +- focusing on the project and the technical topic +- being open to different ideas and levels of experience + +Examples of unacceptable behavior include: + +- harassment or personal attacks +- insulting or dismissive language +- hostile, discriminatory, or abusive behavior +- deliberate disruption of discussions or reviews +- publishing private information without permission + +## Enforcement + +Project maintainers may remove, edit, or reject comments, issues, pull requests, or other contributions that violate these standards. + +## Reporting + +If you experience or witness unacceptable behavior, contact the maintainer: + +- Telegram: [@ilyarolf_dev](https://t.me/ilyarolf_dev) + +## Scope + +This Code of Conduct applies to issues, pull requests, discussions, and other community spaces related to this repository. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..3cdc801e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,113 @@ +# Contributing to AiogramShopBot + +Thank you for your interest in contributing to AiogramShopBot. + +This document explains how to propose changes, report bugs, and submit pull requests in a way that is easy to review and maintain. + +## Ways to contribute + +You can help by: + +- reporting bugs +- improving documentation +- fixing typos and broken links +- proposing new features +- submitting code improvements +- improving tests, deployment, and developer experience + +## Before you start + +Please: + +1. Search existing issues and pull requests first. +2. Open an issue before starting large changes. +3. Keep changes focused and easy to review. + +For small fixes such as typos, wording, or broken links, you can usually open a pull request directly. + +## Reporting bugs + +When reporting a bug, include as much useful context as possible: + +- what you expected to happen +- what actually happened +- steps to reproduce the issue +- logs or error messages +- environment details + - Python version + - OS + - database type and version + - Redis version + - deployment method (local, Docker, VPS) + +## Suggesting features + +When suggesting a feature, please describe: + +- the problem you are trying to solve +- why the feature is useful +- a possible implementation idea +- whether it affects users, admins, deployment, or integrations + +## Pull request guidelines + +Please follow these rules when opening a pull request: + +- keep PRs small and focused +- update documentation when behavior changes +- avoid unrelated refactors in the same PR +- use clear commit messages +- explain the purpose of the change in the PR description + +A good pull request usually includes: + +- a short summary +- the motivation for the change +- screenshots or logs if relevant +- notes about backward compatibility + +## Code style + +General expectations: + +- prefer readable and explicit code +- keep functions focused and maintainable +- avoid unnecessary complexity +- preserve existing project structure unless refactoring is justified + +If you introduce a new dependency, explain why it is needed. + +## Documentation changes + +Documentation improvements are very welcome. + +If you change: + +- setup flow +- environment variables +- admin behavior +- user-facing flows +- referral or payment logic + +please update the related documentation as part of the same pull request. + +## Security-related changes + +Do not open a public issue for sensitive vulnerabilities. + +Please follow the instructions in [SECURITY.md](SECURITY.md). + +## Review process + +Maintainers may request: + +- code changes +- documentation updates +- a smaller scope +- clarification about architecture decisions + +Not every contribution can be merged, but thoughtful contributions are appreciated. + +## License + +By contributing to this repository, you agree that your contributions will be licensed under the same license as the project. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..af38e1a4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,58 @@ +# Security Policy + +## Supported versions + +Security support is provided on a best-effort basis for the most actively maintained code in the default branch. + +If versioned releases are introduced later, this table can be updated. + +| Version | Supported | +|-------------------------|-----------| +| Default branch | ✅ | +| Older snapshots / forks | ❌ | + +## Reporting a vulnerability + +Please do **not** open a public GitHub issue for security vulnerabilities. + +Instead, report vulnerabilities privately through one of the following channels: + +- Telegram: [@ilyarolf_dev](https://t.me/ilyarolf_dev) + +When possible, include: + +- a short description of the issue +- affected component or feature +- steps to reproduce +- proof of concept if available +- impact assessment +- any suggested fix or mitigation + +## What to expect + +After a private report is received, the maintainer will try to: + +1. confirm the issue +2. assess the severity and impact +3. prepare a fix or mitigation +4. publish the patch when appropriate + +Response and remediation times are best-effort and may vary depending on the complexity of the issue. + +## Scope + +Examples of security-relevant areas may include: + +- authentication and admin access +- webhook validation +- JWT handling +- SQLAdmin access control +- payment logic and forwarding logic +- secrets and environment variable handling +- deployment and reverse proxy misconfiguration + +## Disclosure policy + +Please allow reasonable time for the vulnerability to be reviewed and fixed before public disclosure. + +Responsible disclosure helps protect users and deployments of the project. From 8c741f0cecce78489c64b5a29d8869dfa3cd786b Mon Sep 17 00:00:00 2001 From: ilyarolf Date: Tue, 14 Apr 2026 22:28:05 +0300 Subject: [PATCH 2/6] Improve multibot delivery and harden repository boundaries --- .github/workflows/tests.yml | 30 ++ crypto_api/CryptoApiWrapper.py | 200 +++++++-- docs.md | 458 +++++++++++++++++++ enums/cryptocurrency.py | 17 +- enums/keyboard_button.py | 10 +- handlers/admin/announcement.py | 5 +- i18n/de.json | 6 +- i18n/en.json | 6 +- i18n/es.json | 6 +- i18n/fr.json | 6 +- i18n/it.json | 6 +- i18n/zh.json | 6 +- models/item.py | 9 + models/payment.py | 2 +- multibot.py | 23 +- processing/processing.py | 12 +- pytest.ini | 3 + readme.md | 744 ++++--------------------------- repositories/buyItem.py | 8 + repositories/category.py | 15 +- repositories/item.py | 61 ++- repositories/subcategory.py | 9 + repositories/user.py | 7 +- requirements.txt | Bin 2926 -> 1457 bytes services/announcement.py | 19 +- services/buy.py | 15 +- services/cart.py | 88 ++-- services/item.py | 18 +- services/multibot.py | 156 +++++++ services/notification.py | 37 +- services/payment.py | 13 +- services/statistics.py | 8 +- services/user.py | 2 +- services/wallet.py | 9 +- tests/conftest.py | 119 +++++ tests/test_crypto_api_wrapper.py | 117 +++++ tests/test_localizator.py | 13 + tests/test_multibot.py | 352 +++++++++++++++ tests/test_user_repository.py | 41 ++ tests/test_wallet_service.py | 88 ++++ utils/localizator.py | 46 ++ utils/utils.py | 8 +- 42 files changed, 1986 insertions(+), 812 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 docs.md create mode 100644 pytest.ini create mode 100644 services/multibot.py create mode 100644 tests/conftest.py create mode 100644 tests/test_crypto_api_wrapper.py create mode 100644 tests/test_localizator.py create mode 100644 tests/test_multibot.py create mode 100644 tests/test_user_repository.py create mode 100644 tests/test_wallet_service.py create mode 100644 utils/localizator.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..a59e6478 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: Tests + +on: + push: + branches: + - master + - develop + - codex/** + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests + run: pytest diff --git a/crypto_api/CryptoApiWrapper.py b/crypto_api/CryptoApiWrapper.py index 0c9e2571..4b115a9b 100644 --- a/crypto_api/CryptoApiWrapper.py +++ b/crypto_api/CryptoApiWrapper.py @@ -1,67 +1,177 @@ -import aiohttp +import httpx import config +from kryptoexpress import AsyncKryptoExpressClient, verify_callback_signature +from kryptoexpress.models import FiatCurrency as KryptoExpressFiatCurrency +from kryptoexpress.models import CryptoCurrency as KryptoExpressCryptoCurrency from enums.cryptocurrency import Cryptocurrency +from enums.currency import Currency +from enums.payment import PaymentType from enums.withdraw_type import WithdrawType +from models.payment import ProcessingPaymentDTO from models.withdrawal import WithdrawalDTO class CryptoApiWrapper: + @staticmethod + def _map_crypto_to_sdk(cryptocurrency: Cryptocurrency) -> KryptoExpressCryptoCurrency: + return KryptoExpressCryptoCurrency(cryptocurrency.value) + + @staticmethod + def _map_crypto_from_sdk(cryptocurrency: KryptoExpressCryptoCurrency) -> Cryptocurrency: + return Cryptocurrency(cryptocurrency.value) + + @staticmethod + def _map_fiat_to_sdk(currency: Currency) -> KryptoExpressFiatCurrency: + return KryptoExpressFiatCurrency(currency.value) + + @staticmethod + async def _convert_fiat_amount(amount: float, + from_currency: KryptoExpressFiatCurrency, + to_currency: KryptoExpressFiatCurrency) -> float: + if from_currency == to_currency: + return amount + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + "https://api.frankfurter.app/latest", + params={ + "amount": amount, + "from": from_currency.value, + "to": to_currency.value, + }, + ) + response.raise_for_status() + payload = response.json() + return payload["rates"][to_currency.value] + + @staticmethod + def _build_client() -> AsyncKryptoExpressClient: + return AsyncKryptoExpressClient( + api_key=config.KRYPTO_EXPRESS_API_KEY, + base_url=config.KRYPTO_EXPRESS_API_URL, + async_fiat_converter=CryptoApiWrapper._convert_fiat_amount, + ) @staticmethod - async def fetch_api_request(url: str, params: dict | None = None, method: str = "GET", data: str | None = None, - headers: dict | None = None) -> dict: - async with aiohttp.ClientSession() as session: - async with session.request(method, url, params=params, data=data, headers=headers) as response: - if response.status == 200: - data = await response.json() - return data + def _build_processing_payment_dto(payment) -> ProcessingPaymentDTO: + return ProcessingPaymentDTO( + id=payment.id, + paymentType=PaymentType(payment.payment_type.value), + fiatCurrency=Currency(payment.fiat_currency.value), + fiatAmount=payment.fiat_amount, + cryptoAmount=payment.crypto_amount, + cryptoCurrency=Cryptocurrency(payment.crypto_currency.value), + expireDatetime=payment.expire_datetime, + createDatetime=payment.create_datetime, + address=payment.address, + isPaid=payment.is_paid, + isWithdrawn=payment.is_withdrawn, + hash=payment.hash, + callbackUrl=payment.callback_url, + ) + + @staticmethod + def _build_withdrawal_dto(withdrawal) -> WithdrawalDTO: + return WithdrawalDTO( + withdrawType=WithdrawType(withdrawal.withdraw_type.value), + cryptoCurrency=Cryptocurrency(withdrawal.crypto_currency.value), + toAddress=withdrawal.to_address, + txIdList=withdrawal.tx_id_list, + receivingAmount=withdrawal.receiving_amount, + blockchainFeeAmount=withdrawal.blockchain_fee_amount, + serviceFeeAmount=withdrawal.service_fee_amount, + onlyCalculate=withdrawal.only_calculate, + totalWithdrawalAmount=withdrawal.total_withdrawal_amount, + paymentId=withdrawal.payment_id, + ) + + @staticmethod + async def create_invoice(payment_dto: ProcessingPaymentDTO) -> ProcessingPaymentDTO: + async with CryptoApiWrapper._build_client() as client: + if payment_dto.paymentType == PaymentType.PAYMENT: + payment = await client.payments.create_payment( + crypto_currency=CryptoApiWrapper._map_crypto_to_sdk(payment_dto.cryptoCurrency), + fiat_currency=CryptoApiWrapper._map_fiat_to_sdk(payment_dto.fiatCurrency), + fiat_amount=payment_dto.fiatAmount, + callback_url=payment_dto.callbackUrl, + callback_secret=payment_dto.callbackSecret, + ) + else: + payment = await client.payments.create_deposit( + crypto_currency=CryptoApiWrapper._map_crypto_to_sdk(payment_dto.cryptoCurrency), + fiat_currency=CryptoApiWrapper._map_fiat_to_sdk(payment_dto.fiatCurrency), + callback_url=payment_dto.callbackUrl, + callback_secret=payment_dto.callbackSecret, + ) + return CryptoApiWrapper._build_processing_payment_dto(payment) @staticmethod async def get_crypto_prices() -> dict: - url = f"https://api.coingecko.com/api/v3/simple/price" - params = { - "ids": "bitcoin,litecoin,solana,ethereum,binancecoin,tether,usd-coin", - "vs_currencies": "usd,eur,gbp,jpy,cad" - } - return await CryptoApiWrapper.fetch_api_request(url, params) + async with CryptoApiWrapper._build_client() as client: + prices_response = await client.currencies.get_prices( + crypto_currencies=[ + CryptoApiWrapper._map_crypto_to_sdk(cryptocurrency) + for cryptocurrency in Cryptocurrency + ], + fiat_currency=CryptoApiWrapper._map_fiat_to_sdk(config.CURRENCY), + ) + prices = {} + fiat_key = config.CURRENCY.value.lower() + for price in prices_response.prices: + internal_currency = CryptoApiWrapper._map_crypto_from_sdk(price.crypto_currency) + prices[internal_currency.get_coingecko_name()] = {fiat_key: price.price} + return prices @staticmethod async def get_wallet_balance() -> dict[Cryptocurrency, float]: - url = f"{config.KRYPTO_EXPRESS_API_URL}/wallet" - headers = { - "X-Api-Key": config.KRYPTO_EXPRESS_API_KEY - } - response = await CryptoApiWrapper.fetch_api_request( - url, - headers=headers - ) - return {Cryptocurrency(k): v for k, v in response.items()} + async with CryptoApiWrapper._build_client() as client: + wallet = await client.wallet.get() + balances: dict[Cryptocurrency, float] = {} + for currency_name, amount in wallet.model_dump(exclude_none=True).items(): + try: + balances[Cryptocurrency(currency_name)] = amount + except ValueError: + continue + return balances @staticmethod async def withdrawal(cryptocurrency: Cryptocurrency, to_address: str, only_calculate: bool, payment_id: int = None) -> WithdrawalDTO: - url = f"{config.KRYPTO_EXPRESS_API_URL}/wallet/withdrawal" - headers = { - "X-Api-Key": config.KRYPTO_EXPRESS_API_KEY, - "Content-Type": "application/json" - } - if payment_id: - withdraw_type = WithdrawType.SINGLE - else: - withdraw_type = WithdrawType.ALL - body = WithdrawalDTO( - withdrawType=withdraw_type, - cryptoCurrency=cryptocurrency.name, - toAddress=to_address, - onlyCalculate=only_calculate, - paymentId=payment_id - ) - response = await CryptoApiWrapper.fetch_api_request( - url, - method="POST", - data=body.model_dump_json(exclude_none=True), - headers=headers + sdk_currency = CryptoApiWrapper._map_crypto_to_sdk(cryptocurrency) + async with CryptoApiWrapper._build_client() as client: + if payment_id: + if only_calculate: + withdrawal = await client.wallet.calculate_single( + payment_id=payment_id, + crypto_currency=sdk_currency, + to_address=to_address, + ) + else: + withdrawal = await client.wallet.withdraw_single( + payment_id=payment_id, + crypto_currency=sdk_currency, + to_address=to_address, + ) + else: + if only_calculate: + withdrawal = await client.wallet.calculate_all( + crypto_currency=sdk_currency, + to_address=to_address, + ) + else: + withdrawal = await client.wallet.withdraw_all( + crypto_currency=sdk_currency, + to_address=to_address, + ) + return CryptoApiWrapper._build_withdrawal_dto(withdrawal) + + @staticmethod + def verify_callback_signature(signature: str | None, payload: bytes) -> bool: + if not config.KRYPTO_EXPRESS_API_SECRET: + return False + return verify_callback_signature( + raw_body=payload, + callback_secret=config.KRYPTO_EXPRESS_API_SECRET, + signature=signature, ) - return WithdrawalDTO.model_validate(response) \ No newline at end of file diff --git a/docs.md b/docs.md new file mode 100644 index 00000000..89f14b4f --- /dev/null +++ b/docs.md @@ -0,0 +1,458 @@ +# AiogramShopBot Docs + +Full documentation for deployment, configuration, user flows, admin flows, referral logic, SQLAdmin, multibot mode, and media demos. + +## Table of Contents + +- [Deployment and configuration](#deployment-and-configuration) +- [User manual](#user-manual) +- [Admin manual](#admin-manual) +- [Cryptocurrency forwarding](#cryptocurrency-forwarding) +- [Referral system](#referral-system) +- [SQLAdmin web admin panel](#sqladmin-web-admin-panel) +- [Multibot experimental](#multibot-experimental) +- [Demo gallery](#demo-gallery) +- [Todo list](#todo-list) + +## Deployment and Configuration + +### Required environment variables + +| Environment Variable Name | Description | Recommended Value | +|------------------------------|-------------|-------------------| +| `WEBHOOK_PATH` | The path to the webhook where Telegram servers send requests for bot updates. Change it if you deploy several bots on one server. | `/` | +| `WEBAPP_HOST` | Hostname for the Telegram bot service. | `0.0.0.0` for Docker Compose, `localhost` for local deployment | +| `WEBAPP_PORT` | Port for the Telegram bot service. | `5000` | +| `TOKEN` | Telegram bot token from `@BotFather`. | No recommended value | +| `ADMIN_ID_LIST` | Comma-separated list of Telegram IDs that can access the admin menu. | No recommended value | +| `SUPPORT_LINK` | Telegram support profile URL used by the Help button. | `https://t.me/${YOUR_USERNAME_TG}` | +| `POSTGRES_USER` | PostgreSQL username. | `postgres` | +| `POSTGRES_PASSWORD` | PostgreSQL password. | Any strong value | +| `DB_PORT` | PostgreSQL port. | `5432` | +| `DB_HOST` | PostgreSQL host. | `postgres` | +| `NGROK_TOKEN` | Ngrok token for development tunnel mode. | No recommended value | +| `PAGE_ENTRIES` | Pagination size. | `8` | +| `MULTIBOT` | Enables experimental multibot mode. Accepts `"true"` or `"false"`. | `"false"` | +| `CURRENCY` | Fiat currency used in the bot. | `"USD"`, `"EUR"`, `"JPY"`, `"CAD"`, or `"GBP"` | +| `RUNTIME_ENVIRONMENT` | `"dev"` uses ngrok, `"prod"` uses reverse proxy flow. | `"prod"` or `"dev"` | +| `WEBHOOK_SECRET_TOKEN` | Protects Telegram webhook requests from spoofing. | Any strong value | +| `KRYPTO_EXPRESS_API_KEY` | API key from KryptoExpress. | No recommended value | +| `KRYPTO_EXPRESS_API_URL` | KryptoExpress API URL. | `https://KryptoExpress.pro/api` | +| `KRYPTO_EXPRESS_API_SECRET` | Protects KryptoExpress callbacks from spoofing. | Any strong value | +| `REDIS_PASSWORD` | Required for throttling. | Any strong value | +| `REDIS_HOST` | Redis host. | `redis` for Docker Compose | +| `CRYPTO_FORWARDING_MODE` | Enables automatic forwarding of deposits to your own addresses. | `"true"` or `"false"` | +| `BTC_FORWARDING_ADDRESS` | Required when forwarding mode is enabled. | Bech32 BTC address | +| `LTC_FORWARDING_ADDRESS` | Required when forwarding mode is enabled. | Bech32 LTC address | +| `ETH_FORWARDING_ADDRESS` | Required when forwarding mode is enabled. | Ethereum address | +| `SOL_FORWARDING_ADDRESS` | Required when forwarding mode is enabled. | Solana address | +| `BNB_FORWARDING_ADDRESS` | Required when forwarding mode is enabled. | BNB address | +| `MIN_REFERRER_TOTAL_DEPOSIT` | Deposit threshold to unlock referrals. | `"500"` | +| `REFERRAL_BONUS_PERCENT` | Referral bonus percent for invited users. | `"5"` | +| `REFERRAL_BONUS_DEPOSIT_LIMIT` | Number of referred-user deposits eligible for bonus. | `"3"` | +| `REFERRER_BONUS_PERCENT` | Bonus percent for the inviter. | `"3"` | +| `REFERRER_BONUS_DEPOSIT_LIMIT` | Number of deposits per referral that reward the inviter. | `"5"` | +| `REFERRAL_BONUS_CAP_PERCENT` | Cap for referred-user bonus. | `"7"` | +| `REFERRER_BONUS_CAP_PERCENT` | Cap for inviter bonus. | `"7"` | +| `TOTAL_BONUS_CAP_PERCENT` | Global combined bonus cap. | `"12"` | +| `SQLADMIN_RAW_PASSWORD` | Password for SQLAdmin login. | Strong random string | +| `JWT_EXPIRE_MINUTES` | JWT expiration time for SQLAdmin auth. | `"30"` | +| `JWT_ALGORITHM` | JWT algorithm. | `"HS256"` | +| `JWT_SECRET_KEY` | Secret for JWT generation. | Strong random string | + +### Quick start + +Run on a VPS: + +```bash +sudo sh -c "$(curl -fsSL https://raw.githubusercontent.com/ilyarolf/AiogramShopBot/refs/heads/master/scripts/deploy.sh)" +``` + +The interactive script will prompt you for `.env` values. + +### Development and production mode + +For local development on a computer that is not internet-facing, set `RUNTIME_ENVIRONMENT="dev"`. The bot will use an ngrok tunnel. + +> **Note** +> You need a valid ngrok account and token. Redis is still required. + +For production, you can use your own hostname behind Caddy or services like [sslip.io](https://sslip.io/). Caddy can automatically provision TLS and act as a reverse proxy. If you already have a reverse proxy, remove the Caddy service and configure routing yourself. + +### Local development example + +> **Note** +> Local deployment still requires PostgreSQL, Redis, and a reverse proxy or tunnel setup. + +```bash +git clone https://github.com/ilyarolf/AiogramShopBot.git +pip install -r requirements.txt +python run.py +``` + +Example `.env`: + +```env +WEBHOOK_PATH="/" +WEBAPP_HOST="localhost" +WEBAPP_PORT=5000 +TOKEN="1234567890:QWER.....TYI" +ADMIN_ID_LIST=123456,654321 +SUPPORT_LINK="https://t.me/your_username_123" +POSTGRES_USER="postgres" +POSTGRES_PASSWORD="qwertyu" +DB_PORT="5432" +DB_HOST="localhost" +POSTGRES_DB="aiogram-shop-bot" +NGROK_TOKEN="NGROK_TOKEN_HERE" +PAGE_ENTRIES="8" +MULTIBOT="false" +CURRENCY="USD" +RUNTIME_ENVIRONMENT="PROD" +WEBHOOK_SECRET_TOKEN="1234567890" +KRYPTO_EXPRESS_API_KEY="API_KEY_HERE" +KRYPTO_EXPRESS_API_URL="https://kryptoexpress.pro/api" +KRYPTO_EXPRESS_API_SECRET="1234567890" +REDIS_PASSWORD="1234567890" +REDIS_HOST="localhost" +CRYPTO_FORWARDING_MODE="false" +BTC_FORWARDING_ADDRESS="" +LTC_FORWARDING_ADDRESS="" +ETH_FORWARDING_ADDRESS="" +SOL_FORWARDING_ADDRESS="" +BNB_FORWARDING_ADDRESS="" +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="admin" +JWT_EXPIRE_MINUTES="30" +JWT_ALGORITHM="HS256" +JWT_SECRET_KEY="1234567890" +``` + +## User Manual + +### Registration + +User registration happens automatically on the first `/start` command. + +### Top up balance + +- Open `👤 My profile` +- Open `➕ Top Up Balance` +- Select a cryptocurrency +- Copy the payment address +- Send crypto and wait for confirmation + +### Purchase of goods + +Open `All categories`, select a category, select a subcategory, choose quantity, and confirm the purchase. + +### Purchase history + +Open `My Profile -> Purchase History` to see previous purchases and open details for each one. + +## Admin Manual + +### Adding a new admin + +Add the Telegram ID to `ADMIN_ID_LIST`, separated by commas, and restart the bot. + +Example: + +```env +ADMIN_ID_LIST=123456,654321 +``` + +### Announcements + +#### Send to Everyone + +- Open `🔑 Admin Menu` +- Open `📢 Announcements` +- Select `📢 Send to Everyone` +- Type or forward a message +- Confirm or decline the sending + +![Send to Everyone](https://i.imgur.com/JYN6qx0.gif) + +#### Restocking Message + +Generated from items where `is_new = true`. + +![Restocking Message](https://i.imgur.com/lu3khwR.gif) + +#### Current Stock + +Generated from items where `is_sold = false`. + +![Current Stock](https://i.imgur.com/T9wMPRG.gif) + +### Inventory Management + +#### Add Items via JSON + +Send a `.json` file after opening `🔑 Admin Menu -> 📦 Inventory Management -> ➕ Add Items -> JSON`. + +> **Note** +> `private_data` is what the user receives after purchase. + +Example: + +```json +[ + { + "item_type": "Physical", + "category": "Category#1", + "subcategory": "Subcategory#1", + "price": 50, + "description": "Mocked description", + "private_data": null + }, + { + "item_type": "Digital", + "category": "Category#2", + "subcategory": "Subcategory#2", + "price": 100, + "description": "Mocked description", + "private_data": "Mocked private data" + } +] +``` + +![Add Items JSON](https://i.imgur.com/zjS4v8k.gif) + +#### Add Items via TXT + +Open `🔑 Admin Menu -> 📦 Inventory Management -> ➕ Add Items -> TXT` and send a `.txt` file. + +Example: + +```txt +PHYSICAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;null +PHYSICAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;null +PHYSICAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;null +PHYSICAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;null +DIGITAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;PRIVATE_DATA#5 +DIGITAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;PRIVATE_DATA#6 +DIGITAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;PRIVATE_DATA#7 +DIGITAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;PRIVATE_DATA#8 +``` + +![Add Items TXT](https://i.imgur.com/jct3qGc.gif) + +#### Delete category or subcategory + +> **Note** +> Deleting a category or subcategory removes all unsold products bound to it from `All categories`. + +![Delete Category or Subcategory](https://i.imgur.com/foFKU0y.gif) + +### User Management + +#### Credit Management: Add balance + +Find a user by Telegram ID or username and increase their balance. + +![Add Balance](https://i.imgur.com/6HXd460.gif) + +#### Credit Management: Reduce balance + +Find a user by Telegram ID or username and reduce their balance. + +![Reduce Balance](https://i.imgur.com/4JPbWZd.gif) + +#### Make Refund + +Select a purchase from the refund menu and confirm or cancel the refund. + +![Make Refund](https://i.imgur.com/hZ7UvJJ.gif) + +### Analytics and Reports + +#### Statistics + +Open `🔑 Admin Menu -> 📊 Analytics & Reports`, select an entity, then choose the time range. + +![Statistics](https://i.imgur.com/lmuo0QY.gif) + +### Admin notifications + +> **Note** +> All Telegram IDs from `ADMIN_ID_LIST` receive admin notifications. + +#### New deposit notification + +![New Deposit Notification](https://i.imgur.com/FSXzEoW.gif) + +#### New buy notification + +![New Buy Notification](https://i.imgur.com/MeRkCYD.gif) + +### Media management + +Open `🔑 Admin Menu -> 📷 Media management` to change category, subcategory, or button media. + +> **Note** +> Media can be GIFs, images, or videos. + +![Media Management](https://i.imgur.com/VIQdxvL.gif) + +### Coupons management + +#### Create new coupon + +Select the coupon type, usage limit, value, coupon name, then confirm or cancel. + +![Create Coupon](https://i.imgur.com/1tfiFBw.gif) + +#### View all coupons + +Select an existing coupon and choose whether to enable or disable it. + +![View Coupons](https://i.imgur.com/dMZoOA3.gif) + +### Shipping management + +#### Create new shipping option + +Open `🔑 Admin Menu -> 📦 Shipping management -> 🚚 Create new shipping option`. + +![Create Shipping Option](https://i.imgur.com/IqrdGL5.gif) + +#### View all shipping options + +Open `🔑 Admin Menu -> 📦 Shipping management -> 📋 View all shipping options`. + +![View Shipping Options](https://i.imgur.com/E2MHoaK.gif) + +### Buys management + +Used mainly to view user purchases and update tracking numbers for physical orders. + +![Buys Management](https://i.imgur.com/4aPUnHx.gif) + +### Reviews management + +Used to view and moderate customer reviews. + +![Reviews Management](https://i.imgur.com/umBysXX.gif) + +## Cryptocurrency Forwarding + +You can enable cryptocurrency forwarding so deposits received via KryptoExpress are redirected to your own addresses. + +To enable it: +- set `CRYPTO_FORWARDING_MODE=true` +- set the required `{CRYPTO}_FORWARDING_ADDRESS` values in `.env` + +> **Note** +> BTC and LTC forwarding addresses must use Bech32 format. + +## Referral System + +The referral system is designed to stimulate organic growth while keeping the bonus economy controlled and predictable. Bonuses are credited as internal balance and cannot be withdrawn. + +### Access to the referral system + +A user becomes eligible to use referrals only after reaching `MIN_REFERRER_TOTAL_DEPOSIT`. + +### How referrals work + +Each eligible user gets a unique referral link. When a new user joins via that link and later makes a deposit, the referral relationship becomes permanent. + +### Referral bonuses for the referred user + +- `REFERRAL_BONUS_PERCENT` +- `REFERRAL_BONUS_DEPOSIT_LIMIT` +- `REFERRAL_BONUS_CAP_PERCENT` + +### Referrer bonuses for the inviting user + +- `REFERRER_BONUS_PERCENT` +- `REFERRER_BONUS_DEPOSIT_LIMIT` +- `REFERRER_BONUS_CAP_PERCENT` + +### Global bonus cap + +`TOTAL_BONUS_CAP_PERCENT` limits the combined referral and referrer bonuses generated from a single referred user. + +### Anti-abuse guarantees + +- Access is locked behind a minimum deposit requirement +- Bonuses apply only to a limited number of deposits +- Individual and global caps are enforced +- Self-referrals are forbidden + +## SQLAdmin Web Admin Panel + +You can work with database objects using the SQLAdmin admin panel. + +- URL: `{YOUR_IP_ADDRESS}.sslip.io/admin` +- Login: `admin` +- Password: `${SQLADMIN_RAW_PASSWORD}` + +![SQLAdmin Panel](https://i.imgur.com/s1EpxNR.png) + +## Multibot Experimental + +### Starting the multibot + +- Set `MULTIBOT="true"` in Docker Compose +- Start the stack with `docker-compose up` +- This launches one main manager bot +- Child bots are added only through the main bot +- Only Telegram IDs from `ADMIN_ID_LIST` can connect child bots +- To add a managed bot, send `/add {token}` to the main bot +- Connected child bot tokens are stored in Redis under `multibot:tokens` +- On startup, the app restores child bot webhooks from Redis automatically +- In multibot mode, direct user notifications and announcement delivery try all known bot tokens with a short delay between attempts + +![Multibot](https://i.imgur.com/YAGjN3G.png) + +## Demo Gallery + +User-facing demos are shown in the main [README](readme.md). Additional admin demos in this file: + +- Send to Everyone +- Restocking Message +- Current Stock +- Add Items JSON +- Add Items TXT +- Delete Category/Subcategory +- Credit Management +- Refunds +- Statistics +- Admin notifications +- Media management +- Coupons +- Shipping management +- Buys management +- Reviews management + +## Todo List + +- [x] Make migration from direct raw database queries to SQLAlchemy ORM. +- [x] Add option to encrypt database via SQLCipher when using SQLAlchemy. +- [x] Add option to generate new crypto addresses using new mnemonic phrases so that `1 user = 1 mnemonic phrase`. +- [x] Items pagination. +- [x] Database backup from Telegram admin actions. +- [x] Sales and users statistics in Telegram admin. +- [x] Deposit statistics in Telegram admin. +- [x] Product sorting by name, quantity, and price. +- [x] Product search and filtering. +- [x] Cryptocurrency forwarding mode. +- [x] Media for categories, subcategories, and buttons. +- [x] Improved shopping cart with `+1` and `-1` marketplace-like controls. +- [x] Improved user management with blocking. +- [x] Review functionality. +- [x] Support for physical goods with shipping. +- [x] Multiple localization with i18n. +- [x] Referral system. +- [x] SQLAdmin web interface. +- [x] Interactive deployment script. diff --git a/enums/cryptocurrency.py b/enums/cryptocurrency.py index 928acc8f..5b16bf58 100644 --- a/enums/cryptocurrency.py +++ b/enums/cryptocurrency.py @@ -9,6 +9,7 @@ class Cryptocurrency(str, Enum): BNB = "BNB" BTC = "BTC" + DOGE = "DOGE" LTC = "LTC" ETH = "ETH" SOL = "SOL" @@ -21,7 +22,7 @@ class Cryptocurrency(str, Enum): def get_decimals(self): match self: - case Cryptocurrency.BTC | Cryptocurrency.LTC: + case Cryptocurrency.BTC | Cryptocurrency.LTC | Cryptocurrency.DOGE: return 8 case Cryptocurrency.ETH | Cryptocurrency.BNB | Cryptocurrency.USDT_BEP20 | Cryptocurrency.USDC_BEP20: return 18 @@ -36,6 +37,8 @@ def get_coingecko_name(self) -> str: return "bitcoin" case Cryptocurrency.LTC: return "litecoin" + case Cryptocurrency.DOGE: + return "dogecoin" case Cryptocurrency.ETH: return "ethereum" case Cryptocurrency.BNB: @@ -53,6 +56,8 @@ def get_explorer_base_url(self) -> str: return "https://mempool.space" case Cryptocurrency.LTC: return "https://litecoinspace.org" + case Cryptocurrency.DOGE: + return "https://dogechain.info" case Cryptocurrency.ETH | Cryptocurrency.USDT_ERC20 | Cryptocurrency.USDC_ERC20: return "https://etherscan.io" case Cryptocurrency.BNB | Cryptocurrency.USDT_BEP20 | Cryptocurrency.USDC_BEP20: @@ -78,9 +83,19 @@ def get_forwarding_address(self): return config.BNB_FORWARDING_ADDRESS case Cryptocurrency.SOL | Cryptocurrency.USDT_SOL | Cryptocurrency.USDC_SOL: return config.SOL_FORWARDING_ADDRESS + case Cryptocurrency.DOGE: + return None @staticmethod def get_stablecoins() -> list['Cryptocurrency']: return [Cryptocurrency.USDT_SOL, Cryptocurrency.USDC_SOL, Cryptocurrency.USDT_BEP20, Cryptocurrency.USDC_BEP20, Cryptocurrency.USDT_ERC20, Cryptocurrency.USDC_ERC20] + + @staticmethod + def get_hidden() -> set['Cryptocurrency']: + return set() + + @staticmethod + def get_visible() -> list['Cryptocurrency']: + return [cryptocurrency for cryptocurrency in Cryptocurrency if cryptocurrency not in Cryptocurrency.get_hidden()] diff --git a/enums/keyboard_button.py b/enums/keyboard_button.py index 34e00470..1e699b0b 100644 --- a/enums/keyboard_button.py +++ b/enums/keyboard_button.py @@ -1,9 +1,8 @@ -import json from enum import Enum -from pathlib import Path from enums.bot_entity import BotEntity from enums.language import Language +from utils.localizator import Localizator from utils.utils import get_text @@ -21,10 +20,5 @@ def get_localized(self, language: Language): @staticmethod def get_localized_set(button: 'KeyboardButton') -> set[str]: - locale_files = list(Path("./i18n").glob("*.json")) - localized = [] bot_entity = BotEntity.ADMIN if button == KeyboardButton.ADMIN_MENU else BotEntity.USER - for locale_file in locale_files: - with locale_file.open("r", encoding="utf-8") as f: - localized.append(json.loads(f.read())[bot_entity.name.lower()][button.value.lower()]) - return set(localized) + return Localizator.get_all_texts(bot_entity, button.value.lower()) diff --git a/handlers/admin/announcement.py b/handlers/admin/announcement.py index e0d56f7e..cdfbf518 100644 --- a/handlers/admin/announcement.py +++ b/handlers/admin/announcement.py @@ -33,8 +33,9 @@ async def send_everyone(**kwargs): @announcement_router.message(AdminIdFilter(), StateFilter(AnnouncementStates.announcement_msg)) async def receive_admin_message(message: Message, state: FSMContext, language: Language): await state.clear() - if message.text and message.text.lower() == "cancel": - await message.answer(text=get_text(language, BotEntity.ADMIN, "cancelled")) + cancel_text = get_text(language, BotEntity.COMMON, "cancel") + if message.text and message.text.casefold() == cancel_text.casefold(): + await message.answer(text=get_text(language, BotEntity.COMMON, "cancelled")) else: kb_builder = AnnouncementsConstants.get_confirmation_builder(AnnouncementType.FROM_RECEIVING_MESSAGE, language) diff --git a/i18n/de.json b/i18n/de.json index f1e975af..f3ca9f41 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -53,6 +53,10 @@ "restocking_message_header": "🆕 Neue Lagerbestände! 🆕\n", "sales_statistics": "📊 Verkaufsstatistiken der letzten {timedelta}.\n💰 Gesamtgewinn: {currency_sym}{total_profit:.2f}\n🛍️ Verkaufte Artikel: {items_sold}\n💼 Gesamtkäufe: {buys_count}", "send_everyone": "📢 An alle senden", + "multibot_invalid_token": "❌ Ungültiger Bot-Token. Verwenden Sie /add {token}.", + "multibot_main_token_not_allowed": "⚠️ Der Haupt-Bot-Token kann nicht als untergeordneter Bot hinzugefügt werden.", + "multibot_bot_already_added": "ℹ️ Dieser Bot ist bereits verbunden.", + "multibot_bot_added": "✅ Bot @{bot_username} wurde erfolgreich verbunden.", "sending_started": "🚀 Versand gestartet", "stock": "📦 Vollständige Lagerbestandsmeldung", "statistics": "📊 Analytik und Berichte", @@ -145,6 +149,7 @@ "back_button": "⬅️ Zurück", "btc_top_up": "₿ BTC", "bnb_top_up": "BNB (Binance Coin)", + "doge_top_up": "DOGE", "confirm": "✅ Bestätigen", "cancel": "❌ Abbrechen", "cancelled": "❌ Abgebrochen!", @@ -245,7 +250,6 @@ "top_up_balance_msg": "💵 Sende den gewünschten Betrag in {crypto_name} an die angegebene Adresse, um das Guthaben aufzuladen.\n\nStatus der Zahlung: {status}\n\nWichtig\nFür jede Einzahlung wird eine einzigartige BTC/LTC/SOL/ETH-Adresse generiert.\nDie Gutschrift erfolgt innerhalb von 5 Minuten nach der Überweisung.\nNach erfolgreicher Aufladung erhältst du eine Benachrichtigung.\n\nDeine {crypto_name}-Adresse:\n{addr}", "usdc_erc20_top_up": "USDC ERC-20", "usdt_erc20_top_up": "USDT ERC-20", - "usdt_trc20_top_up": "USDT TRC-20", "checkout": "🛍️ Bestellung abschließen", "delete_cart_item_confirmation": "📂 Kategorie: {category_name}\n📦 Unterkategorie: {subcategory_name}\n💸 Preis: {currency_sym}{price:.2f}\n✍️ Beschreibung: {description}\n\n⚠️ Möchtest du diesen Artikel wirklich aus dem Warenkorb entfernen?", "delete_cart_item_confirmation_text": "🗑 Artikel wurde aus dem Warenkorb entfernt", diff --git a/i18n/en.json b/i18n/en.json index ee680358..122cfacb 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -53,6 +53,10 @@ "restocking_message_header": "🆕 New Stock Alert! 🆕\n", "sales_statistics": "📊 Sales statistics for the last {timedelta}.\n\uD83D\uDCB0 Total profit: {currency_sym}{total_profit:.2f}\n\uD83D\uDECD\uFE0F Items sold: {items_sold}\n\uD83D\uDCBC Total buys: {buys_count}", "send_everyone": "📢 Send to Everyone", + "multibot_invalid_token": "❌ Invalid bot token. Use /add {token}.", + "multibot_main_token_not_allowed": "⚠️ The main bot token cannot be added as a child bot.", + "multibot_bot_already_added": "ℹ️ This bot is already connected.", + "multibot_bot_added": "✅ Bot @{bot_username} connected successfully.", "sending_started": "🚀 Sending started", "stock": "📦 Full Inventory Message", "statistics": "📊 Analytics & Reports ", @@ -145,6 +149,7 @@ "back_button": "⬅️ Back", "btc_top_up": "₿ BTC", "bnb_top_up": "BNB (Binance Coin)", + "doge_top_up": "DOGE", "confirm": "✅ Confirm", "cancel": "❌ Cancel", "cancelled": "❌ Cancelled!", @@ -244,7 +249,6 @@ "top_up_balance_button": "➕ Top Up Balance", "usdc_erc20_top_up": "USDC ERC-20", "usdt_erc20_top_up": "USDT ERC-20", - "usdt_trc20_top_up": "USDT TRC-20", "checkout": "\uD83D\uDECD\uFE0F Checkout", "delete_cart_item_confirmation": "\uD83D\uDDC2\uFE0F Category: {category_name}\n\uD83D\uDCE6 Subcategory: {subcategory_name}\n\uD83D\uDCB8Price: {currency_sym}{price:.2f}\n✍\uFE0FDescription: {description}\n\n⚠️ Are you sure you want to remove an item from the cart?", "delete_cart_item_confirmation_text": "🗑 Item successfully removed from cart", diff --git a/i18n/es.json b/i18n/es.json index 73b58ea6..828e3aac 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -53,6 +53,10 @@ "restocking_message_header": "🆕 ¡Nueva llegada de stock! 🆕\n", "sales_statistics": "📊 Estadísticas de ventas de los últimos {timedelta}.\n💰 Beneficio total: {currency_sym}{total_profit:.2f}\n🛍️ Artículos vendidos: {items_sold}\n💼 Total de compras: {buys_count}", "send_everyone": "📢 Enviar a todos", + "multibot_invalid_token": "❌ Token de bot no válido. Usa /add {token}.", + "multibot_main_token_not_allowed": "⚠️ El token del bot principal no se puede agregar como bot secundario.", + "multibot_bot_already_added": "ℹ️ Este bot ya está conectado.", + "multibot_bot_added": "✅ El bot @{bot_username} se conectó correctamente.", "sending_started": "🚀 Envío iniciado", "stock": "📦 Mensaje de inventario completo", "statistics": "📊 Analítica y reportes", @@ -145,6 +149,7 @@ "back_button": "⬅️ Atrás", "btc_top_up": "₿ BTC", "bnb_top_up": "BNB (Binance Coin)", + "doge_top_up": "DOGE", "confirm": "✅ Confirmar", "cancel": "❌ Cancelar", "cancelled": "❌ ¡Cancelado!", @@ -245,7 +250,6 @@ "top_up_balance_msg": "💵 Envía la cantidad deseada en {crypto_name} a la dirección indicada para recargar el saldo del bot.\n\nEstado del pago: {status}\n\nImportante\nSe asigna una dirección única BTC/LTC/SOL/ETH para cada depósito.\nLa recarga se procesa en un plazo de 5 minutos después de la transferencia.\nRecibirás una notificación al completarse.\n\nTu dirección {crypto_name}:\n{addr}", "usdc_erc20_top_up": "USDC ERC-20", "usdt_erc20_top_up": "USDT ERC-20", - "usdt_trc20_top_up": "USDT TRC-20", "checkout": "🛍️ Finalizar compra", "delete_cart_item_confirmation": "📂 Categoría: {category_name}\n📦 Subcategoría: {subcategory_name}\n💸 Precio: {currency_sym}{price:.2f}\n✍️ Descripción: {description}\n\n⚠️ ¿Seguro que deseas eliminar este artículo del carrito?", "delete_cart_item_confirmation_text": "🗑 Artículo eliminado del carrito", diff --git a/i18n/fr.json b/i18n/fr.json index a791cf56..513fe861 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -53,6 +53,10 @@ "restocking_message_header": "🆕 Nouveau stock disponible ! 🆕\n", "sales_statistics": "📊 Statistiques des ventes des {timedelta} derniers jours.\n💰 Profit total : {currency_sym}{total_profit:.2f}\n🛍️ Articles vendus : {items_sold}\n💼 Nombre total d’achats : {buys_count}", "send_everyone": "📢 Envoyer à tout le monde", + "multibot_invalid_token": "❌ Jeton de bot invalide. Utilisez /add {token}.", + "multibot_main_token_not_allowed": "⚠️ Le jeton du bot principal ne peut pas être ajouté comme bot enfant.", + "multibot_bot_already_added": "ℹ️ Ce bot est déjà connecté.", + "multibot_bot_added": "✅ Le bot @{bot_username} a été connecté avec succès.", "sending_started": "🚀 Envoi lancé", "stock": "📦 Annonce complète du stock", "statistics": "📊 Analyses et rapports", @@ -145,6 +149,7 @@ "back_button": "⬅️ Retour", "btc_top_up": "₿ BTC", "bnb_top_up": "BNB (Binance Coin)", + "doge_top_up": "DOGE", "confirm": "✅ Confirmer", "cancel": "❌ Annuler", "cancelled": "❌ Annulé !", @@ -245,7 +250,6 @@ "top_up_balance_msg": "💵 Envoyez le montant souhaité en {crypto_name} à l’adresse indiquée pour recharger votre solde.\n\nStatut du paiement : {status}\n\nImportant\nUne adresse unique BTC/LTC/SOL/ETH est générée pour chaque dépôt.\nLa recharge est effectuée dans un délai de 5 minutes.\nVous recevrez une notification une fois complétée.\n\nVotre adresse {crypto_name} :\n{addr}", "usdc_erc20_top_up": "USDC ERC-20", "usdt_erc20_top_up": "USDT ERC-20", - "usdt_trc20_top_up": "USDT TRC-20", "checkout": "🛍️ Passer la commande", "delete_cart_item_confirmation": "📂 Catégorie : {category_name}\n📦 Sous-catégorie : {subcategory_name}\n💸 Prix : {currency_sym}{price:.2f}\n✍️ Description : {description}\n\n⚠️ Voulez-vous vraiment supprimer cet article du panier ?", "delete_cart_item_confirmation_text": "🗑 Article supprimé du panier", diff --git a/i18n/it.json b/i18n/it.json index 07e3e682..605a6b7b 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -53,6 +53,10 @@ "restocking_message_header": "🆕 Nuovo stock disponibile! 🆕\n", "sales_statistics": "📊 Statistiche delle vendite degli ultimi {timedelta}.\n💰 Profitto totale: {currency_sym}{total_profit:.2f}\n🛍️ Articoli venduti: {items_sold}\n💼 Numero totale di acquisti: {buys_count}", "send_everyone": "📢 Invia a tutti", + "multibot_invalid_token": "❌ Token del bot non valido. Usa /add {token}.", + "multibot_main_token_not_allowed": "⚠️ Il token del bot principale non può essere aggiunto come bot secondario.", + "multibot_bot_already_added": "ℹ️ Questo bot è già collegato.", + "multibot_bot_added": "✅ Bot @{bot_username} collegato con successo.", "sending_started": "🚀 Invio avviato", "stock": "📦 Annuncio completo magazzino", "statistics": "📊 Statistiche e report", @@ -145,6 +149,7 @@ "back_button": "⬅️ Indietro", "btc_top_up": "₿ BTC", "bnb_top_up": "BNB (Binance Coin)", + "doge_top_up": "DOGE", "confirm": "✅ Conferma", "cancel": "❌ Annulla", "cancelled": "❌ Annullato!", @@ -245,7 +250,6 @@ "top_up_balance_msg": "💵 Invia l’importo desiderato in {crypto_name} all’indirizzo indicato per ricaricare il saldo.\n\nStato pagamento: {status}\n\nImportante\nPer ogni deposito viene generato un indirizzo unico BTC/LTC/SOL/ETH.\nL’accredito avviene in circa 5 minuti.\nRiceverai una notifica una volta completato.\n\nIl tuo indirizzo {crypto_name}:\n{addr}", "usdc_erc20_top_up": "USDC ERC-20", "usdt_erc20_top_up": "USDT ERC-20", - "usdt_trc20_top_up": "USDT TRC-20", "checkout": "🛍️ Conferma acquisto", "delete_cart_item_confirmation": "📂 Categoria: {category_name}\n📦 Sottocategoria: {subcategory_name}\n💸 Prezzo: {currency_sym}{price:.2f}\n✍️ Descrizione: {description}\n\n⚠️ Eliminare questo articolo dal carrello?", "delete_cart_item_confirmation_text": "🗑 Articolo rimosso dal carrello", diff --git a/i18n/zh.json b/i18n/zh.json index d54077d5..8720c443 100644 --- a/i18n/zh.json +++ b/i18n/zh.json @@ -53,6 +53,10 @@ "restocking_message_header": "🆕 新库存已上架! 🆕\n", "sales_statistics": "📊 过去 {timedelta} 的销售统计。\n💰 总利润:{currency_sym}{total_profit:.2f}\n🛍️ 售出商品:{items_sold}\n💼 总购买次数:{buys_count}", "send_everyone": "📢 群发消息", + "multibot_invalid_token": "❌ 无效的机器人令牌。请使用 /add {token}。", + "multibot_main_token_not_allowed": "⚠️ 主机器人令牌不能作为子机器人添加。", + "multibot_bot_already_added": "ℹ️ 这个机器人已经连接。", + "multibot_bot_added": "✅ 机器人 @{bot_username} 已成功连接。", "sending_started": "🚀 开始发送", "stock": "📦 全部库存公告", "statistics": "📊 分析与统计", @@ -145,6 +149,7 @@ "back_button": "⬅️ 返回", "btc_top_up": "₿ BTC", "bnb_top_up": "BNB(币安币)", + "doge_top_up": "DOGE", "confirm": "✅ 确认", "cancel": "❌ 取消", "cancelled": "❌ 已取消!", @@ -245,7 +250,6 @@ "top_up_balance_msg": "💵 请将金额发送至以下 {crypto_name} 地址,以完成余额充值。\n\n支付状态:{status}\n\n重要说明:\n每次充值都会生成独立的 BTC/LTC/SOL/ETH 地址。\n充值通常在 5 分钟内到账。\n完成后您将收到通知。\n\n您的 {crypto_name} 地址:\n{addr}", "usdc_erc20_top_up": "USDC ERC-20", "usdt_erc20_top_up": "USDT ERC-20", - "usdt_trc20_top_up": "USDT TRC-20", "checkout": "🛍️ 结算", "delete_cart_item_confirmation": "📂 分类:{category_name}\n📦 子分类:{subcategory_name}\n💸 价格:{currency_sym}{price:.2f}\n✍️ 描述:{description}\n\n⚠️ 是否将此商品从购物车移除?", "delete_cart_item_confirmation_text": "🗑 商品已从购物车移除", diff --git a/models/item.py b/models/item.py index 1d15c177..d79ba6cc 100644 --- a/models/item.py +++ b/models/item.py @@ -44,6 +44,15 @@ class ItemDTO(BaseModel): description: str | None = None +class ItemAvailabilityDTO(BaseModel): + item_type: ItemType + category_id: int + subcategory_id: int + price: float + description: str + available_qty: int + + class ItemAdmin(ModelView, model=Item): column_exclude_list = [Item.category_id, Item.subcategory_id] column_formatters = {Item.private_data: lambda m, a: f"{m.private_data[:20]}..." if m.private_data else "", diff --git a/models/payment.py b/models/payment.py index b1b057fc..9e18bd49 100644 --- a/models/payment.py +++ b/models/payment.py @@ -39,7 +39,7 @@ class ProcessingPaymentDTO(BaseModel): isWithdrawn: bool | None = None hash: str | None = None callbackUrl: str = f'{config.WEBHOOK_URL}cryptoprocessing/event' - callbackSecret: str | None = config.KRYPTO_EXPRESS_API_SECRET if len(config.KRYPTO_EXPRESS_API_SECRET) > 0 else None + callbackSecret: str | None = config.KRYPTO_EXPRESS_API_SECRET if config.KRYPTO_EXPRESS_API_SECRET else None class TablePaymentDTO(BaseModel): diff --git a/multibot.py b/multibot.py index 357e006f..0186d9cd 100644 --- a/multibot.py +++ b/multibot.py @@ -17,7 +17,11 @@ setup_application, ) from db import create_db_and_tables +from enums.bot_entity import BotEntity +from enums.language import Language +from services.multibot import MultibotService from utils.custom_filters import AdminIdFilter +from utils.utils import get_text main_router_multibot = Router() @@ -40,21 +44,34 @@ def is_bot_token(value: str) -> bool | Dict[str, Any]: return True -@main_router_multibot.message(AdminIdFilter(), Command("add", magic=F.args.func(is_bot_token))) +@main_router_multibot.message(AdminIdFilter(), Command("add")) async def command_add_bot(message: Message, command: CommandObject, bot: Bot) -> Any: + if not command.args or not is_bot_token(command.args): + return await message.answer(get_text(Language.EN, BotEntity.ADMIN, "multibot_invalid_token")) + if command.args == config.TOKEN: + return await message.answer(get_text(Language.EN, BotEntity.ADMIN, "multibot_main_token_not_allowed")) + if await MultibotService.has_token(command.args): + return await message.answer(get_text(Language.EN, BotEntity.ADMIN, "multibot_bot_already_added")) + new_bot = Bot(token=command.args, session=bot.session) try: bot_user = await new_bot.get_me() except TelegramUnauthorizedError: - return message.answer("Invalid token") + return await message.answer(get_text(Language.EN, BotEntity.ADMIN, "multibot_invalid_token")) await new_bot.delete_webhook(drop_pending_updates=True) await new_bot.set_webhook(OTHER_BOTS_URL.format(bot_token=command.args)) - return await message.answer(f"Bot @{bot_user.username} successful added") + await MultibotService.add_token(command.args) + return await message.answer( + get_text(Language.EN, BotEntity.ADMIN, "multibot_bot_added").format( + bot_username=bot_user.username + ) + ) async def on_startup(dispatcher: Dispatcher, bot: Bot): await bot.set_webhook(f"{BASE_URL}{MAIN_BOT_PATH}") await create_db_and_tables() + await MultibotService.restore_child_bot_webhooks(OTHER_BOTS_URL) for admin in config.ADMIN_ID_LIST: try: await bot.send_message(admin, 'Bot is working') diff --git a/processing/processing.py b/processing/processing.py index eef776ba..4eb2601e 100644 --- a/processing/processing.py +++ b/processing/processing.py @@ -1,8 +1,4 @@ import datetime -import hashlib -import hmac -import re - from fastapi import APIRouter, Request, HTTPException import config @@ -19,13 +15,7 @@ def __security_check(x_signature_header: str | None, payload: bytes): - if x_signature_header is None: - return True - else: - secret_key = config.KRYPTO_EXPRESS_API_SECRET.encode("utf-8") - hmac_sha512 = hmac.new(secret_key, re.sub(rb'\s+', b'', payload), hashlib.sha512) - generated_signature = hmac_sha512.hexdigest() - return hmac.compare_digest(generated_signature, x_signature_header) + return CryptoApiWrapper.verify_callback_signature(x_signature_header, payload) @processing_router.post("/event") diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..0e02926d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -p no:cacheprovider diff --git a/readme.md b/readme.md index 9d6d0d43..b359beb3 100644 --- a/readme.md +++ b/readme.md @@ -1,19 +1,23 @@

AiogramShopBot

+

+ 🛍️ Production-style Telegram shop bot with crypto payments, admin flows, FastAPI webhooks, and multibot support. +

+

Live bot - Business_offer + Business offer

- [![Python](https://img.shields.io/badge/Python_3.12-3776AB?logo=python&logoColor=%23fff)](https://www.python.org/downloads/release/python-3127/) [![Dockerhub](https://img.shields.io/badge/Docker_Hub-2496ED?logo=docker&logoColor=fff)](https://hub.docker.com/r/ilyarolf/aiogram-shop-bot) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-316192?logo=postgresql&logoColor=white)](https://www.sqlite.org/) [![Bitcoin](https://img.shields.io/badge/Bitcoin-FF9900?logo=bitcoin&logoColor=white)](https://github.com/bitcoin/bitcoin) +[![Dogecoin](https://img.shields.io/badge/Dogecoin-C2A633?logo=dogecoin&logoColor=white)](https://dogecoin.com/) [![Litecoin](https://img.shields.io/badge/Litecoin-A6A9AA?logo=litecoin&logoColor=white)](https://github.com/litecoin-project/litecoin) [![Solana](https://img.shields.io/badge/Solana-9945FF?logo=solana&logoColor=fff)](https://github.com/solana-labs/solana) [![Ethereum](https://img.shields.io/badge/Ethereum-3C3C3D?logo=ethereum&logoColor=white)](https://github.com/ethereum) @@ -21,693 +25,123 @@ [![Tether](https://img.shields.io/badge/Tether-168363?&logo=tether&logoColor=white)](https://tether.to/) [![USD Coin](https://img.shields.io/badge/USD%20Coin-2775CA?&logo=usd-coin&logoColor=white)](https://www.usdc.com/) -**AiogramShopBot is a software product based on Aiogram3, SQLAlchemy, and SQLAdmin that allows you to automate the sale of digital and physical goods in Telegram. One of the advantages of the bot is that AiogramShopBot implements the ability to replenish funds using Bitcoin, Litecoin, Solana, Ethereum, Binance Coin and Stablecoins, which allows you to sell goods worldwide.
-The bot implements the most popular features: a referral system, a review feature, a web admin panel for working with database objects, multiple i18n localization, and much more.** - ---- - -* [🤝 Commercial offers](#commercial-offers) - + [➤ Telegram. ](#-for-commercial-offers-contact-me-on-telegram) - + [🤖 AiogramShopBotDemo](#-you-can-test-the-functionality-in-aiogramshopbotdemo). -* [✨ Donate](#donate-) -* [1.Launch the bot](#1starting-the-bot) - + [1.0 Description of required environment variables. ](#10-description-of-required-environment-variables) - + [1.1 Quick start.](#11-quick-start) - + [1.2 Launch AiogramShopBot without SQLCipher database encryption.](#12-starting-aiogramshopbot-locally) -* [2. 👥 AiogramShopBot User's Manual](#2aiogramshopbot-users-manual) - + [2.1 🖥️ Registration](#21-registration) - + [2.2 ➕ Top Up Balance](#22--top-up-balance) - + [2.3 👜 Purchase of goods](#23-purchase-of-goods) - + [2.4 🧾 Purchase History](#24--purchase-history) -* [3. 🔑 AiogramShopBot Admin Manual](#3aiogramshopbot-admin-manual) - + [3.1 🔑 Adding a new admin](#31-adding-a-new-admin) - + [3.2 📢 Announcements](#32--announcements) - - [3.2.1 📢 Send to Everyone](#321--send-to-everyone) - - [3.2.2 🔄 Restocking Message](#322--restocking-message) - - [3.2.3 🗂️ Current Stock](#323--current-stock) - + [3.3 📦 Inventory Management](#33--inventory-management) - - [3.3.1 ➕ Add Items](#331--add-items) - - [3.3.1.1 JSON](#3311-json) - - [3.3.1.2 TXT](#3312-txt) - + [3.4 👥 User Management](#34--user-management) - - [3.4.1 💳 Credit Management](#341--credit-management) - - [3.4.1.1 ➕ Add balance](#3411--add-balance) - - [3.4.1.2 ➖ Reduce balance](#3412--reduce-balance) - - [3.4.2 ↩️ Make Refund](#342--make-refund) - + [3.5 📊 Analytics & Reports](#35--analytics--reports) - - [3.5.1 📊 Statistics](#351--statistics) - + [3.6 🔔 Admin notifications](#36--admin-notifications) - - [3.6.1 Notification to admin about new deposit](#361-notification-to-admin-about-new-deposit) - - [3.6.2 Notification to admin about new buy](#362-notification-to-admin-about-new-buy) - + [3.8 👛 Wallet](#38--wallet) - - [3.8.1 Cryptocurrency withdrawal](#381-cryptocurrency-withdrawal-functionality) - + [3.9 📷 Media management](#39--media-management) - + [3.10 🎪 Coupons management](#310--coupons-management) - - [3.10.1 🎫 Create new coupon](#3101--create-new-coupon) - - [3.10.2 📋 View all coupons](#3102--view-all-coupons) - + [3.11 📦 Shipping management](#311--shipping-management) - - [3.11.1 🚚 Create new shipping option](#3111--create-new-shipping-option) - - [3.11.2 📋 View all shipping options](#3112--view-all-shipping-options) - + [3.12 🛍 Buys management](#312--buys-management) - + [3.13 ⭐ Reviews Management](#313--reviews-management) -* [4.0 Cryptocurrency Forwarding ](#40-cryptocurrency-forwarding-) -* [5.0 Referral System](#50-referral-system) - + [5.1 Access to the Referral System](#51-access-to-the-referral-system) - + [5.2 How Referrals Work](#52-how-referrals-work) - + [5.3 Referral Bonuses (Referred User)](#53-referral-bonuses-referred-user) - + [5.4 Referrer Bonuses (Inviting User)](#54-referrer-bonuses-inviting-user) - + [5.5 Global Bonus Cap](#55-global-bonus-cap) - + [5.6 Anti-Abuse Guarantees](#56-anti-abuse-guarantees) -* [6.0 Admin panel with web interface (SQLAdmin)](#60-admin-panel-with-web-interface-sqladmin) -* [7.0 Multibot (Experimental functionality)](#70-multibot-experimental-functionality) - + [5.1 Starting the multibot](#71-starting-the-multibot) -* [📋 Todo List](#-todo-list) -* [MIT License](LICENSE) - -## 📌Commercial offers - -### ➤ For commercial offers contact me on [Telegram](https://t.me/ilyarolf_dev). - -### 🤖 You can test the functionality in [AiogramShopBotDemo](https://t.me/demo_aiogramshopbot). - -## Donate ✨ - -* BTC - bc1q2kv89q8yvf068xxw3x65gzfag98l9wnrda3x56 -* LTC - ltc1q0tuvm5vqn9le5zmhvhtp7z9p2eu6yvv24ey686 -* TRX - THzRw8UpTsEYBEG5CCbsCVnJzopSHFHJm6 -* SOL - Avm7VAqPrwpHteXKfDTRFjpj6swEzjmj3a2KQvVDvugK -* ETH - 0xB49D720DE2630fA4C813d5B4c025706E25cF74fe -* TON - UQD0QetwXoYTsmbZWVbE_z_JUFh54RVVRUxCbCHQkLsl3Hfn -* USDT ERC20 - 0xB49D720DE2630fA4C813d5B4c025706E25cF74fe -* USDT BEP20 - 0xB49D720DE2630fA4C813d5B4c025706E25cF74fe -* USDT TRC20 - THzRw8UpTsEYBEG5CCbsCVnJzopSHFHJm6 - ---- - -## 1.Starting the bot - -### 1.0 Description of required environment variables - -| Environment Variable Name | Description | Recommend Value | -|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------| -| WEBHOOK_PATH | The path to the webhook where Telegram servers send requests for bot updates. It is not recommended to change it if only one bot will be deployed. In case several bots will be deployed on the same server, it will be necessary to change it, because there will be path collision (Does not apply to the multibot case). | "/" | -| WEBAPP_HOST | Hostname for Telegram bot, it is not recommended to change in case you use docker-compose. | For docker-compose="0.0.0.0".
For local deployment="localhost". | -| WEBAPP_PORT | Port for Telegram bot, if you plan to deploy several bots on the same server, you will need to assign a different port to each one (Not relevant to the multibot case). | 5000 | -| TOKEN | Token from your Telegram bot, you can get it for free in Telegram from the bot of all bots with the username @botfather. | No recommended value | -| ADMIN_ID_LIST | List of Telegram id of all admins of your bot. This list is used to check for access to the admin menu. | No recommended value | -| SUPPORT_LINK | A link to the Telegram profile that will be sent by the bot to the user when the “Help” button is pressed. | https://t.me/${YOUR_USERNAME_TG} | -| POSTGRES_USER | PostgreSQL username. | postgres | -| POSTGRES_PASSWORD | PostgreSQL password. | Any string you want | -| DB_PORT | PostgreSQL port. | 5432 | -| DB_HOST | PostgreSQL host. | postgres | -| NGROK_TOKEN | Token from your NGROK profile, it is needed for port forwarding to the Internet. The main advantage of using NGROK is that NGROK assigns the HTTPS certificate for free. | No recommended value | -| PAGE_ENTRIES | The number of entries per page. Serves as a variable for pagination. | 8 | -| MULTIBOT | Experimental functionality, allows you to raise several bots in one process. And there will be one main bot, where you can create other bots with the command “/add $BOT_TOKEN”. Accepts string parameters “true” or “false”. | "false" | -| CURRENCY | Currency to be used in the bot. | "USD" or "EUR" or "JPY" or "CAD" or "GBP" | -| RUNTIME_ENVIRONMENT | If set to "dev", the bot will be connected via an ngrok tunnel. "prod" will use [Caddy](https://hub.docker.com/r/lucaslorentz/caddy-docker-proxy) as reverse proxy together with your public hostname | "prod" or "dev" | -| WEBHOOK_SECRET_TOKEN | Required variable, used to protect requests coming from Telegram servers from spoofing. | Any string you want | -| KRYPTO_EXPRESS_API_KEY | API KEY from KryptoExpress profile | No recommended value | -| KRYPTO_EXPRESS_API_URL | API URL from KryptoExpress service | https://KryptoExpress.pro/api | -| KRYPTO_EXPRESS_API_SECRET | Required variable, used to protect requests coming from KryptoExpress servers from spoofing. | Any string you want | -| REDIS_PASSWORD | Required variable, needed to make the throttling mechanism work. | Any string you want | -| REDIS_HOST | Required variable, needed to make the throttling mechanism work. | "redis" for docker-compose.yml | -| CRYPTO_FORWARDING_MODE | Optional variable, when CRYPTO_FORWARDING_MODE is enabled, all deposits are automatically transferred to your addresses. | "true" or "false" | -| BTC_FORWARDING_ADDRESS | Optional variable, mandatory if CRYPTO_FORWARDING_MODE=true, BECH32 format only. | BECH32 format only. | -| LTC_FORWARDING_ADDRESS | Optional variable, mandatory if CRYPTO_FORWARDING_MODE=true. BECH32 format only. | BECH32 format only. | -| ETH_FORWARDING_ADDRESS | Optional variable, mandatory if CRYPTO_FORWARDING_MODE=true. | Ethereum address. | -| SOL_FORWARDING_ADDRESS | Optional variable, mandatory if CRYPTO_FORWARDING_MODE=true. | Solana address. | -| BNB_FORWARDING_ADDRESS | Optional variable, mandatory if CRYPTO_FORWARDING_MODE=true. | Binance-Coin address. | -| MIN_REFERRER_TOTAL_DEPOSIT | Optional variable, mandatory if CRYPTO_FORWARDING_MODE=true. | "500" | -| REFERRAL_BONUS_PERCENT | A mandatory variable for the referral system to work. | "5" | -| REFERRAL_BONUS_DEPOSIT_LIMIT | A mandatory variable for the referral system to work. | "3" | -| REFERRER_BONUS_PERCENT | A mandatory variable for the referral system to work. | "3" | -| REFERRER_BONUS_DEPOSIT_LIMIT | A mandatory variable for the referral system to work. | "5" | -| REFERRAL_BONUS_CAP_PERCENT | A mandatory variable for the referral system to work. | "7" | -| REFERRER_BONUS_CAP_PERCENT | A mandatory variable for the referral system to work. | "7" | -| TOTAL_BONUS_CAP_PERCENT | A mandatory variable for the referral system to work. | "12" | -| SQLADMIN_RAW_PASSWORD | Required variable for SQLAdmin to work. | A random string of 32 characters. | -| JWT_EXPIRE_MINUTES | Required variable for generating a JWT token. | "30" | -| JWT_ALGORITHM | Required variable for generating a JWT token. | "HS256" | -| JWT_SECRET_KEY | Required variable for generating a JWT token. | A random string of 32 characters. | - -### 1.1 Quick start. -Connect to your VPS via SSH and run this command. -An interactive script will prompt you for variables for .env. -``` -sudo sh -c "$(curl -fsSL https://raw.githubusercontent.com/ilyarolf/AiogramShopBot/refs/heads/master/scripts/deploy.sh)" -``` - ---- - -#### Development and production mode - -For local development on a computer which is not internet facing, set the "RUNTIME_ENVIRONMENT" to "dev". The bot will -be connected via an ngrok tunnel. -> **⚠️ Note**
-> **To get the ngrok token, you need to register on the ngrok website and confirm your email. Then you will have the -ngrok token in your personal account.
You will still need Redis.** - -On an internet facing production system you can either set your own hostname in the caddy label (in the template shown -with "YOUR_DOMAIN_GOES_HERE" -or make use of services -like [sslip.io](https://sslip.io/). [Caddy](https://hub.docker.com/r/lucaslorentz/caddy-docker-proxy) will automatically -pull a TLS certificate -and serves as reverse proxy for your bot. You can also run your bot together with an already existing reverse proxy. In -this case you have to remove the caddy service from the docker-compose file and configure the reverse proxy accordingly. - ---- - -### 1.2 Starting AiogramShopBot locally. -> **⚠️ Note**
-> **Please note that in order to deploy the bot locally, you must have a reverse proxy, Redis, and PostgreSQL configured.** - -* Clone the project from the master branch.
``git clone https://github.com/ilyarolf/AiogramShopBot.git`` -* Install all necessary packages
``pip install -r requirements.txt`` -* Set the environment variables to run in the .env file.
Example: - -``` -WEBHOOK_PATH = "/" -WEBAPP_HOST = "localhost" -WEBAPP_PORT = 5000 -TOKEN = "1234567890:QWER.....TYI" -ADMIN_ID_LIST = 123456,654321 -SUPPORT_LINK = "https://t.me/your_username_123" -POSTGRES_USER = "postgres" -DB_ENCRYPTION = "false" -POSTGRES_PASSWORD = "qwertyu" -DB_PORT = "5432" -DB_HOST = "localhost" -POSTGRES_DB = "aiogram-shop-bot" -NGROK_TOKEN = "NGROK_TOKEN_HERE" -PAGE_ENTRIES = "8" -MULTIBOT = "false" -CURRENCY = "USD" -RUNTIME_ENVIRONMENT = "PROD" -WEBHOOK_SECRET_TOKEN = "1234567890" -KRYPTO_EXPRESS_API_KEY = "API_KEY_HERE" -KRYPTO_EXPRESS_API_URL = "https://kryptoexpress.pro/api" -KRYPTO_EXPRESS_API_SECRET = "1234567890" -REDIS_PASSWORD = "1234567890" -REDIS_HOST = "localhost" -CRYPTO_FORWARDING_MODE = "false" -BTC_FORWARDING_ADDRESS = "" -LTC_FORWARDING_ADDRESS = "" -ETH_FORWARDING_ADDRESS = "" -SOL_FORWARDING_ADDRESS = "" -BNB_FORWARDING_ADDRESS = "" -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 = "admin" -JWT_EXPIRE_MINUTES = "30" -JWT_ALGORITHM = "HS256" -JWT_SECRET_KEY = "1234567890" -``` - -* After these steps the bot is ready to run, launch the bot with command ```python run.py``` - ---- - -## 2.AiogramShopBot User's Manual - ---- - -### 2.1 Registration - -User registration occurs when the bot is first accessed with the ``/start`` command. - ---- - -### 2.2 ➕ Top Up Balance - -* Open my profile menu using the “👤 My profile” button. -* Open top-up menu using the “➕ Top Up Balance” button. -* In the resulting menu, click on cryptocurrency name button. -* Copy cryptocurrency address, and send cryptocurrency on this address. -* Once your transaction has at least one confirmation you will receive notification from the bot. - -
![img](https://i.imgur.com/j2l7fHc.gif) - ---- - -### 2.3 Purchase of goods - -To buy any item, go to "All categories" -> Select any category -> Select any subcategory -> Select quantity -> Confirm -purchase. If the purchase is successful, you will immediately receive a message in the format: - -![img](https://i.imgur.com/yEUw32h.gif) - ---- - -### 2.4 🧾 Purchase History - -* To access your purchase history go to "My Profile" -> "Purchase History". -* You will be presented with an inline keyboard with all your purchases, by clicking on any of the purchases you will be - sent a message in the format from paragraph 2.3. - -![imb](https://i.imgur.com/t5sA38N.gif) - ---- - -## 3.AiogramShopBot Admin Manual - ---- - -### 3.1 Adding a new admin - -To add a new admin you need to add his telegram id to the ADMIN_ID_LIST environment variable, separated by commas, and -reload the bot.
For example: ``ADMIN_ID_LIST=123456,654321`` - ---- +**AiogramShopBot** is an open source Telegram shop bot built with **Aiogram 3**, **FastAPI**, **SQLAlchemy async**, **PostgreSQL**, **Redis**, **Docker Compose**, and **SQLAdmin**. It helps you sell **digital goods** and **physical goods** inside Telegram with built-in **cryptocurrency payments**, **shopping cart**, **purchase history**, **admin tools**, **shipping**, **reviews**, **coupons**, **analytics**, **referrals**, and **multi-language support**. -### 3.2 📢 Announcements +This repository is designed for developers and product teams who need a production-style Telegram ecommerce bot with a web admin panel, payment processing, localization, and scalable deployment. ---- +## ✨ Why This Project -### 3.2.1 📢 Send to Everyone +- Sell digital and physical products directly in Telegram. +- Accept crypto top-ups with Bitcoin, Dogecoin, Litecoin, Solana, Ethereum, Binance Coin, USDT, and USDC networks. +- Manage inventory, users, coupons, shipping, reviews, media, and purchases from Telegram admin flows. +- Use PostgreSQL, Redis, webhook mode, and Docker-based deployment for production setups. +- Extend the project with FastAPI routes, SQLAdmin, repositories, services, and Aiogram handlers. -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the announcements menu using the “📢 Announcements” button. -* In the resulting menu, click on “📢 Send to Everyone” button. -* Type a message or forward to the bot, the bot supports sending a message with pictures and Telegram markup (bold, - italics, spoilers, etc.). -* Confirm or decline the sending of messages. -* After successful message sending, the original message with inline buttons "Confirm", "Decline" will change like on - gif.
+## 🚀 Core Features -![img](https://i.imgur.com/JYN6qx0.gif) +- Telegram storefront with categories, subcategories, cart, checkout, and purchase history. +- Admin menu for announcements, inventory, user management, analytics, wallet operations, media, coupons, shipping, buys, and reviews. +- Crypto payment integration with KryptoExpress. +- Referral system with limits and anti-abuse rules. +- Localization through JSON translation files. +- SQLAdmin web panel for database objects. +- Docker Compose setup for local and production-like environments. +- Multibot mode with one main bot and managed child bots. ---- +## 🧰 Tech Stack -### 3.2.2 🔄 Restocking Message +- Python 3.12 +- Aiogram 3 +- FastAPI +- SQLAlchemy async +- PostgreSQL +- Redis +- Alembic +- SQLAdmin +- Docker Compose -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the announcements menu using the “📢 Announcements” button. -* In the resulting menu, click on “🔄 Restocking Message” button. -* This message is generated based on items in the database that have "is_new" is true. +## ⚡ Quick Start -![img](https://i.imgur.com/lu3khwR.gif) +### 🖥️ Interactive deployment ---- - -### 3.2.3 🗂️ Current Stock - -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the announcements menu using the “📢 Announcements” button. -* In the resulting menu, click on “🗂️ Current Stock” button. -* This message is generated based on items in the database that have "is_sold" is false. - -![img](https://i.imgur.com/T9wMPRG.gif) - ---- - -### 3.3 📦 Inventory Management - ---- - -#### 3.3.1 ➕ Add Items - ---- - -##### 3.3.1.1 JSON - -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the announcements menu using the “📦 Inventory Management” button. -* Open the add items menu using the “➕ Add Items” button. -* In the resulting menu, click on “JSON” button. -* Send .json file with new items.
Example of .json file: - -> **⚠️ Note**
-> The "private_data" property is what the user gets when they make a purchase. - -``` -[ - { - "item_type": "Physical", - "category": "Category#1", - "subcategory": "Subcategory#1", - "price": 50, - "description": "Mocked description", - "private_data": null - }, - { - "item_type": "Digital", - "category": "Category#2", - "subcategory": "Subcategory#2", - "price": 100, - "description": "Mocked description", - "private_data": "Mocked private data" - } -] +Run the installer script on your VPS: +```bash +sudo sh -c "$(curl -fsSL https://raw.githubusercontent.com/ilyarolf/AiogramShopBot/refs/heads/master/scripts/deploy.sh)" ``` -![img](https://i.imgur.com/zjS4v8k.gif) - ---- - -##### 3.3.1.2 TXT +### 💻 Local development -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the inventory management menu using the “📦 Inventory Management” button. -* Open the add items menu using the “➕ Add Items” button. -* In the resulting menu, click on “TXT” button. -* Send .txt file with new items.
Example of .txt file: - -``` -PHYSICAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;null -PHYSICAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;null -PHYSICAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;null -PHYSICAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;null -DIGITAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;PRIVATE_DATA#5 -DIGITAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;PRIVATE_DATA#6 -DIGITAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;PRIVATE_DATA#7 -DIGITAL;CATEGORY#1;SUBCATEGORY#1;DESCRIPTION#1;50.0;PRIVATE_DATA#8 +```bash +git clone https://github.com/ilyarolf/AiogramShopBot.git +cd AiogramShopBot +pip install -r requirements.txt +python run.py ``` -![img](https://i.imgur.com/jct3qGc.gif) - ---- - -#### 3.3.2 🗑️ Delete Category/Subcategory - -> ⚠️ Note
-> This way, you will delete all products from “All categories” with the category or subcategory you picked and deleted. - -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the inventory management menu using the “📦 Inventory Management” button. -* Open the add items menu using the “🗑️ Delete Category” or “🗑️ Delete Subcategory” button. -* In the resulting menu, select the category or subcategory you want to delete. -* Confirm or cancel the deletion of the category or subcategory. - -![img](https://i.imgur.com/foFKU0y.gif) - ---- - -### 3.4 👥 User Management - ---- - -#### 3.4.1 💳 Credit Management - ---- - -##### 3.4.1.1 ➕ Add balance - -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the user management menu using the “👥 User Management” button. -* Open the credit management menu using the “💳 Credit Management” button. -* In the resulting menu, click on “➕ Add balance” button. -* Send the user entity object that belongs to the user you want to add the balance to. This can be telegram id or - telegram username. -* Send the value by which you want to add the balance to the user. - -![img](https://i.imgur.com/6HXd460.gif) - ---- - -##### 3.4.1.2 ➖ Reduce balance - -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the user management menu using the “👥 User Management” button. -* Open the credit management menu using the “💳 Credit Management” button. -* In the resulting menu, click on “➖ Reduce balance” button. -* Send the user entity object that belongs to the user you want to add the balance to. This can be telegram id or - telegram username. -* Send the value by which you want to reduce the balance to the user. - -![img](https://i.imgur.com/4JPbWZd.gif) - ---- - -#### 3.4.2 ↩️ Make Refund - -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the user management menu using the “👥 User Management” button. -* Open the refund menu using the “↩️ Make Refund” button. -* In the resulting menu, click on the buy button you want to refund. -* Confirm or cancel refund. - -![img](https://i.imgur.com/hZ7UvJJ.gif) - ---- - -### 3.5 📊 Analytics & Reports - ---- - -### 3.5.1 📊 Statistics - -* Open the admin menu using the “🔑 Admin Menu” button. -* Open the statistics menu using the “📊 Analytics & Reports” button. -* In the resulting menu, click on the entity for which you want to get statistics. -* In the resulting menu, click on the time period for which you want statistics. - -![img](https://i.imgur.com/lmuo0QY.gif) - ---- - -### 3.6 🔔 Admin notifications - -> **⚠️ Note**
-> All users with telegram id in the .env ADMIN_ID_LIST environment variable will receive these notifications - ---- - -#### 3.6.1 Notification to admin about new deposit - -* If any user topped up the balance and clicked on the "Refresh balance" button, you will receive the following message - from the bot: - -![img](https://i.imgur.com/FSXzEoW.gif) - ---- - -#### 3.6.2 Notification to admin about new buy - -After each purchase, you will receive a message in the format: - -![img](https://i.imgur.com/MeRkCYD.gif) - ---- - -### 3.8 👛 Wallet - ---- - -#### 3.8.1 Cryptocurrency withdrawal functionality - -To withdraw cryptocurrency from the bot, open the admin menu, go to the wallet tab, select the cryptocurrency you want to withdraw, send the cryptocurrency address where you want to withdraw and confirm the withdrawal. After a successful withdrawal, the bot will send you a link to the blockchain browser with the transaction. - -![img](https://i.imgur.com/gjkRFVb.gif) - ---- - -### 3.9 📷 Media management - -To change the media for a category, subcategory, or text buttons, open 🔑 Admin Menu->📷 Media management. -> **⚠️ Note**
-> Media can be GIFs, images, or videos. - -![img](https://i.imgur.com/VIQdxvL.gif) - ---- - -### 3.10 🎪 Coupons management - -With 🎪 Coupons management, you can create a new coupon or modify an existing one. - ---- - -#### 3.10.1 🎫 Create new coupon - -To create a coupon, select the coupon type—either 📊 Percentage or 💰 Fixed. Then, choose the usage limit: ♾️ Infinite or 1️⃣ Single-use. Next, enter the coupon value (discount percentage or fixed amount). Provide a coupon name, and finally confirm or cancel the creation. - -![img](https://i.imgur.com/1tfiFBw.gif) - ---- - -#### 3.10.2 📋 View all coupons - -You can disable or enable coupons. Open 🔑 Admin Menu->🎪 Coupons management->📋 View all coupons, select a coupon by name, and choose an action. - -![img](https://i.imgur.com/dMZoOA3.gif) - ---- - -### 3.11 📦 Shipping management - -#### 3.11.1 🚚 Create new shipping option - -To create a new shipping method, open 🔑 Admin Menu->📦 Shipping management->🚚 Create new shipping option, then enter the name of the shipping method and the cost in fiat currency. - -![img](https://i.imgur.com/IqrdGL5.gif) - ---- - -#### 3.11.2 📋 View all shipping options - -You can change the price, name, or disable an existing shipping method. Open 🔑 Admin Menu->📦 Shipping management->📋 View all shipping options, then select the shipping method and choose the desired action. - -![img](https://i.imgur.com/E2MHoaK.gif) - ---- - -#### 3.12 🛍 Buys management - -With 🛍 Buys management, you can view all your users' purchases. This feature is mainly used to update tracking numbers for physical goods purchases. After the tracking number is updated, the user will receive a notification. - -![img](https://i.imgur.com/4aPUnHx.gif) - ---- - -#### 3.13 ⭐ Reviews Management - -With ⭐ Reviews Management, you can view all reviews of your customers' purchases. You can also delete the text and image of a review to avoid unwanted advertising. - -![img](https://i.imgur.com/umBysXX.gif) - ---- - -## 4.0 Cryptocurrency Forwarding - -You can enable cryptocurrency forwarding, in which case all cryptocurrency will be redirected from KryptoExpress addresses to your addresses.
-To enable cryptocurrency forwarding, you need to set the CRYPTO_FORWARDING_MODE variable to true and set values for {CRYPTO}_FORWARDING_ADDRESS in .env. -> **⚠️ Note**
-> BTC and LTC addresses must be in Bech32 format. - ---- - -## 5.0 Referral System - -The referral system is designed to stimulate organic growth of the bot while keeping the bonus economy fully controlled and predictable. All referral rewards are credited as internal bonus balance and cannot be withdrawn. Bonuses can only be used to purchase digital or physical products inside the bot. - ---- - -### 5.1 Access to the Referral System - -A user can become a referrer only after reaching a minimum total deposit amount. - -- **MIN_REFERRER_TOTAL_DEPOSIT** - The minimum total amount of deposits required to unlock the referral system. - -Until this threshold is reached, the user cannot generate or use a referral link. - ---- - -### 5.2 How Referrals Work - -Each eligible user can generate a unique referral link. -When a new user joins the bot using this link and makes a deposit, the referral relationship is permanently established. - -Referral bonuses are applied only to deposits made **after** the referral relationship is created. - ---- - -### 5.3 Referral Bonuses (Referred User) - -The referred user receives a bonus added to their balance on eligible deposits. - -- **REFERRAL_BONUS_PERCENT** - Percentage bonus applied to each eligible deposit made by the referred user. - -- **REFERRAL_BONUS_DEPOSIT_LIMIT** - Number of deposits that can receive the referral bonus. - -- **REFERRAL_BONUS_CAP_PERCENT** - Maximum total bonus the referred user can receive, expressed as a percentage of their own deposits. - -If the cap is reached, no further referral bonuses are granted. - ---- - -### 5.4 Referrer Bonuses (Inviting User) - -The referrer earns a bonus from the deposits made by each referred user. - -- **REFERRER_BONUS_PERCENT** - Percentage of the referred user’s deposit credited to the referrer. - -- **REFERRER_BONUS_DEPOSIT_LIMIT** - Number of deposits per referred user that generate a referrer bonus. - -- **REFERRER_BONUS_CAP_PERCENT** - Maximum total bonus the referrer can earn from a single referred user, expressed as a percentage of that referral’s deposits. +You will also need PostgreSQL, Redis, environment variables, and webhook/reverse proxy configuration. ---- +Full setup guide: +- [Deployment and environment variables](docs.md#deployment-and-configuration) +- [Local run example](docs.md#local-development-example) -### 5.5 Global Bonus Cap +## 🎬 Product Walkthrough -To ensure economic safety, a global cap limits the total bonuses generated from a single referral. +### 💳 User balance top-up -- **TOTAL_BONUS_CAP_PERCENT** - The maximum combined bonus (referral + referrer) that can be generated from a referred user, expressed as a percentage of that user’s deposits. +![Top Up Balance Demo](https://i.imgur.com/j2l7fHc.gif) -If the combined bonuses exceed this limit, the system prioritizes the referred user’s bonus. Any remaining bonus capacity is applied to the referrer. +### 🛒 Product purchase flow ---- +![Purchase Flow Demo](https://i.imgur.com/yEUw32h.gif) -### 5.6 Anti-Abuse Guarantees +### 🧾 Purchase history -The referral system includes multiple safeguards: -- Referral access is locked behind a minimum deposit requirement -- Bonuses apply only to a limited number of deposits -- Individual and global bonus caps prevent excessive rewards -- Self-referrals are explicitly forbidden +![Purchase History Demo](https://i.imgur.com/t5sA38N.gif) -These measures ensure sustainable growth and predictable costs without relying on product margins. +### 👛 Admin wallet withdrawal ---- +![Wallet Demo](https://i.imgur.com/gjkRFVb.gif) +More Telegram bot GIF demos, admin flow examples, and screenshots are available in [docs.md](docs.md#demo-gallery). -## 6.0 Admin panel with web interface (SQLAdmin) +## 📚 Documentation -You can work with database objects using the SQLAdmin admin panel.
-This panel is always available at {YOUR_IP_ADDRESS}.sslip.io/admin
-Login: admin
-Password: ${SQLADMIN_RAW_PASSWORD} +- [Full documentation](docs.md) +- [Deployment and configuration](docs.md#deployment-and-configuration) +- [User manual](docs.md#user-manual) +- [Admin manual](docs.md#admin-manual) +- [Referral system](docs.md#referral-system) +- [Cryptocurrency forwarding](docs.md#cryptocurrency-forwarding) +- [SQLAdmin panel](docs.md#sqladmin-web-admin-panel) +- [Multibot mode](docs.md#multibot-experimental) -![img.png](https://i.imgur.com/s1EpxNR.png) +## 🎯 Use Cases -## 7.0 Multibot (Experimental functionality) +- Telegram shop bot for digital products +- Telegram bot for physical goods with shipping +- Crypto-funded Telegram marketplace +- Telegram multibot commerce setup with one manager bot +- Aiogram ecommerce starter project +- FastAPI + Aiogram + SQLAlchemy production template +- Telegram admin panel and back office automation -### 7.1 Starting the multibot +## 🤝 Commercial Contact -* Set all environment variables in docker-compose.yml and set the variable “true” for MULTIBOT. - ``MULTIBOT="true"`` -* Run the ``docker-compose up`` command. -* After successful execution of the command, you will only deploy a manager bot for other bots, it will not have - functionality for buying items etc. To deploy a bot with functionality to sell goods etc..., you need to send the - command ``/add $TOKEN`` to the bot manager. If everything is successful, you will receive this notification. +- Demo bot: [@demo_aiogramshopbot](https://t.me/demo_aiogramshopbot) +- Commercial requests: [@ilyarolf_dev](https://t.me/ilyarolf_dev) -![img](https://i.imgur.com/YAGjN3G.png) +## 💖 Donate -## 📋 Todo List +- BTC: `bc1q2kv89q8yvf068xxw3x65gzfag98l9wnrda3x56` +- DOGE: `D8BFXqDM7MHf3A4j3kC8wWEN8DqRLVQjax` +- LTC: `ltc1q0tuvm5vqn9le5zmhvhtp7z9p2eu6yvv24ey686` +- SOL: `Avm7VAqPrwpHteXKfDTRFjpj6swEzjmj3a2KQvVDvugK` +- ETH: `0xB49D720DE2630fA4C813d5B4c025706E25cF74fe` +- TON: `UQD0QetwXoYTsmbZWVbE_z_JUFh54RVVRUxCbCHQkLsl3Hfn` +- USDT ERC20: `0xB49D720DE2630fA4C813d5B4c025706E25cF74fe` +- USDT BEP20: `0xB49D720DE2630fA4C813d5B4c025706E25cF74fe` -- [x] Make migration from direct raw database queries to SQLAlchemy ORM. -- [x] Add option to encrypt database via SQLCipher (when using SQLAlchemy). -- [x] Add an option to generate new crypto addresses using new mnemonic phrases so that 1 user=1 mnemonic phrase. -- [x] Items pagination. -- [x] Make the functionality of database backup by action in the admin in the Telegram bot. -- [x] Make the functionality of generating statistics of sales and users in the bot for a month/week/day in the admin - panel. -- [x] Make the functionality of generating statistics of deposits in the bot for a month/week in the admin - panel. -- [x] Functionality for sorting products by name/quantity/price. -- [x] Functionality for searching products by name (filtering). -- [x] Cryptocurrency forwarding mode. -- [x] Functionality for adding media to categories/subcategories/buttons. Media can be GIFs, images, or videos. -- [x] Improved shopping cart functionality, added the ability to add and remove products as in marketplaces (+1/-1 buttons). -- [x] Improved User Management functionality, added user blocking functionality. -- [x] Review functionality. -- [x] Functionality for selling physical goods with shipping. -- [x] Multiple localization (i18n), the bot will respond in the language of your Telegram application or in English by default. -- [x] Referral system. -- [x] Web interface for the admin panel. (SQLAdmin) -- [x] Interactive script for deploying the bot without programming skills, etc. +## 📄 License +[MIT License](LICENSE) diff --git a/repositories/buyItem.py b/repositories/buyItem.py index 28be4c57..1e956eaf 100644 --- a/repositories/buyItem.py +++ b/repositories/buyItem.py @@ -36,6 +36,14 @@ async def get_all_by_buy_id(buy_id: int, session: Session | AsyncSession) -> lis buy_items = await session_execute(stmt, session) return [BuyItemDTO.model_validate(buy_item, from_attributes=True) for buy_item in buy_items.scalars().all()] + @staticmethod + async def get_all_by_buy_ids(buy_ids: list[int], session: Session | AsyncSession) -> list[BuyItemDTO]: + if not buy_ids: + return [] + stmt = select(BuyItem).where(BuyItem.buy_id.in_(buy_ids)) + buy_items = await session_execute(stmt, session) + return [BuyItemDTO.model_validate(buy_item, from_attributes=True) for buy_item in buy_items.scalars().all()] + @staticmethod async def get_paginated_by_buy_id(sort_pairs: dict[str, int], filters: list[str], diff --git a/repositories/category.py b/repositories/category.py index 2d9d9dd6..ab104eb0 100644 --- a/repositories/category.py +++ b/repositories/category.py @@ -102,7 +102,7 @@ async def get_to_delete(sort_pairs: dict[str, int], categories.scalars().all()] @staticmethod - async def get_or_create(category_name: str, session: Session | AsyncSession): + async def get_or_create(category_name: str, session: Session | AsyncSession) -> CategoryDTO: stmt = select(Category).where(Category.name == category_name) category = await session_execute(stmt, session) category = category.scalar() @@ -111,9 +111,16 @@ async def get_or_create(category_name: str, session: Session | AsyncSession): new_category_obj = Category(name=category_name, media_id=f"0{bot_photo_id}") session.add(new_category_obj) await session_flush(session) - return new_category_obj - else: - return category + category = new_category_obj + return CategoryDTO.model_validate(category, from_attributes=True) + + @staticmethod + async def get_by_ids(category_ids: list[int], session: Session | AsyncSession) -> list[CategoryDTO]: + if not category_ids: + return [] + stmt = select(Category).where(Category.id.in_(category_ids)) + categories = await session_execute(stmt, session) + return [CategoryDTO.model_validate(category, from_attributes=True) for category in categories.scalars().all()] @staticmethod async def update(category_dto: CategoryDTO, session: AsyncSession): diff --git a/repositories/item.py b/repositories/item.py index 0a24e9ac..ead7ffa7 100644 --- a/repositories/item.py +++ b/repositories/item.py @@ -1,11 +1,11 @@ -from sqlalchemy import select, func, update, delete, and_ +from sqlalchemy import select, func, update, delete, and_, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Session from db import session_execute from enums.item_type import ItemType from models.buyItem import BuyItem -from models.item import Item, ItemDTO +from models.item import Item, ItemDTO, ItemAvailabilityDTO class ItemRepository: @@ -52,6 +52,11 @@ async def get_by_id(item_id: int, session: AsyncSession) -> ItemDTO: item = await session_execute(stmt, session) return ItemDTO.model_validate(item.scalar(), from_attributes=True) + @staticmethod + async def get_by_id_map(item_ids: list[int], session: AsyncSession | Session) -> dict[int, ItemDTO]: + items = await ItemRepository.get_by_id_list(item_ids, session) + return {item.id: item for item in items if item.id is not None} + @staticmethod async def get_purchased_items(category_id: int, subcategory_id: int, quantity: int, session: Session | AsyncSession) -> list[ItemDTO]: @@ -119,6 +124,58 @@ async def get_by_id_list(item_ids: list[int], session: AsyncSession) -> list[Ite items = await session_execute(stmt, session) return [ItemDTO.model_validate(item, from_attributes=True) for item in items.scalars().all()] + @staticmethod + async def get_availability_by_cart_items( + cart_items: list, + session: AsyncSession | Session + ) -> dict[tuple[ItemType, int, int], ItemAvailabilityDTO]: + unique_keys = sorted({ + (cart_item.item_type, cart_item.category_id, cart_item.subcategory_id) + for cart_item in cart_items + }, key=lambda item: (item[0].value, item[1], item[2])) + if not unique_keys: + return {} + + conditions = [ + and_( + Item.item_type == item_type, + Item.category_id == category_id, + Item.subcategory_id == subcategory_id, + ) + for item_type, category_id, subcategory_id in unique_keys + ] + partition_by = (Item.item_type, Item.category_id, Item.subcategory_id) + ranked_items = ( + select( + Item.item_type, + Item.category_id, + Item.subcategory_id, + Item.price, + Item.description, + func.count().over(partition_by=partition_by).label("available_qty"), + func.row_number().over(partition_by=partition_by, order_by=Item.id).label("row_number"), + ) + .where(Item.is_sold == False, or_(*conditions)) + .subquery() + ) + stmt = ( + select( + ranked_items.c.item_type, + ranked_items.c.category_id, + ranked_items.c.subcategory_id, + ranked_items.c.price, + ranked_items.c.description, + ranked_items.c.available_qty, + ) + .where(ranked_items.c.row_number == 1) + ) + rows = await session_execute(stmt, session) + result: dict[tuple[ItemType, int, int], ItemAvailabilityDTO] = {} + for row in rows.mappings().all(): + dto = ItemAvailabilityDTO.model_validate(row, from_attributes=True) + result[(dto.item_type, dto.category_id, dto.subcategory_id)] = dto + return result + @staticmethod async def get_available_item_types(session: AsyncSession) -> list[ItemType]: stmt = select(Item.item_type).where(Item.is_sold == False).distinct() diff --git a/repositories/subcategory.py b/repositories/subcategory.py index ba63ca57..d23c9c8b 100644 --- a/repositories/subcategory.py +++ b/repositories/subcategory.py @@ -141,3 +141,12 @@ async def update(subcategory_dto: SubcategoryDTO, session: AsyncSession): .where(Subcategory.id == subcategory_dto.id) .values(**subcategory_dto.model_dump())) await session_execute(stmt, session) + + @staticmethod + async def get_by_ids(subcategory_ids: list[int], session: Session | AsyncSession) -> list[SubcategoryDTO]: + if not subcategory_ids: + return [] + stmt = select(Subcategory).where(Subcategory.id.in_(subcategory_ids)) + subcategories = await session_execute(stmt, session) + return [SubcategoryDTO.model_validate(subcategory, from_attributes=True) + for subcategory in subcategories.scalars().all()] diff --git a/repositories/user.py b/repositories/user.py index bd89544a..29bbff69 100644 --- a/repositories/user.py +++ b/repositories/user.py @@ -116,8 +116,11 @@ async def get_max_page_by_timedelta(timedelta: StatisticsTimeDelta, session: Ses @staticmethod async def get_by_referrer_code(referrer_code: str, session: AsyncSession) -> UserDTO | None: stmt = select(User).where(User.referral_code == referrer_code) - user_dto = await session_execute(stmt, session) - return user_dto.scalar_one_or_none() + user = await session_execute(stmt, session) + user = user.scalar_one_or_none() + if user is None: + return None + return UserDTO.model_validate(user, from_attributes=True) @staticmethod async def get_referrals_qty_by_referrer_id(referrer_id: int, session: AsyncSession) -> int: diff --git a/requirements.txt b/requirements.txt index 8bf3a04791f7586088dd7cd92d654669f493f04b..f2fc96f3e9e9ffdec71538060b47f43edf315a95 100644 GIT binary patch literal 1457 zcmYLJO>f*F5WVwXjBLCg$-#%JQV&&AwQ8lRr;5d_cd{4=;BA~=-#0i(r9JGxc;?M} zZ_pIZ1p^=O9hNtHzwqgx32F z^qazSWu#qI1@Ur-o%N^_jSZl(l)R{gA>JODYQ*?kIBp_BsC0s zd60WyY${Y+w;gAp=*1=`gn2WL%yk&<0QgRrK;A6r1}c~c zz78&cX3d~>@_^Wvu%x(d>;XkGa~L@~;y;fAS>grvy8|j9VKt81fqaD_tr>Hz)-h{# zRQr8*-<+JjT6N+wkXM{_E;@opRt(fAaYo+~0#6SZ&_$e6c(LA5E*pQXnC#7ZMC=gG zn36?;#R+8I;-|&t@-E<|>uL)C6>O4cjufa6Gk?fkjzABmfarN~g+?+M?SQ(i@XO4Su;n`uwYj2?hm+v77IDt?YiJ~QB9e3A zFQ7sWzD*$-Z7!Sr`Cx5wdX?qfwBV&)>Fg_KXp2M>*;W`Ev?z}2++&z4cvZ`5BP-z5Pbq-3Bf4@QZd2#!>&C{lT!L>#tR!#5R=Slj zE?9uQmObH_c+*_uC#T1me03kw{NwwV z4-SgVRZ=X`L0}ð_x^*eoWpz{?D5_Z~I%3E3)hAv{;0?T>v17ny>u+XLN|SdxP! z`YHghFE@f76+zGCe*OG}mjdbRa3xkWujr2$?j=MkUssJMt1)>v`m7C@LJqS20m)Rf ASpWb4 literal 2926 zcmZveQBT}R5QOLXr2Ldc*b5;%LzJBEM5I%mqQ$$w2JAK7C4v0-OX}gwv69{>7xk=ALGuJ!MITBU7ztEWyU=_pltkJGF4M{>+f8uVKm5%X9lY8r8? z^MDzHGQ z#jWt8x>k(Vsfh?ss^F6==#*n4ghik%(@Z~~#VxA>+SvrL(<<7*2_?=aYl9eXgjWlt zk-F$>Wlt3~RrdcPXTh^eOYQ1}l=XYLcdOM(_(SmAja)!?r1jZqwNq3tBsfsV{D1>J z81`AHrPga*P^?!KkBY$~nTzXnFB|dQ2%!#qa=+Gl8a-^J-w=C?2FHpd%NxD(G0&k& zWX}wmgsF$Moybhwoz3>`Yr_b#yLyg zjWEcC>lYD21UX*saXN9@1P;Cjhdgk}+6>aZRm^g~dN%GZee>DsRjDyfLP2+mE;jl* z6`^JBVHI8SNm*%iDTNBlYU0P0)@t?RRNT&`;FXXTQf8&UUhP>?m(i}Zx>a4AX8RT< zl^*-54*u7wiR!5zrR)r=g9aIE&?;T(6tfQ8tP*H;*^Soe19IEluH}ZjbIkmS@MNtr+n!#EcF=GmD{yRuZt#A_2pKg~^P)^E+@YFX&`W$3- z_R5`Be?%P_p;YmtI-ObURi{1*Jc5n<)o#YI+u!v4@lP zzx!a zjLI{NIQnPZ{t~-C#qFK6vu?f{+%ve-4Lt2RiQ-l^GtZUzCISO=J0!>Kq&Tx+7Tj@c zzAcE){L!sf9A~rSpYFwl%z*EEuI%v~foqBV7C6PzQzuR1igR6uoeL^2%2NX>eMuMT zQ~DU0Jr%Oz57GyXm!rnqCGG{P;~Yw5g|csU`w&iB$n>|4ckfni19=n5{=-_qkkaXwM4RLcD% zc0S&Mz9|O9F_))bV7|qymA|FzqOXd4ipWk(`G(>r*q#}1@GkCW&OK9MzRzwQspC;z z;hb;+N)_|KF~UFTb9x`^?U=*f%^9BK#cg3<{M0_a|Bm!dZ>Q3M5 0 + availability = availability_map.get( + (cart_item.item_type, cart_item.category_id, cart_item.subcategory_id) + ) + is_available = availability is not None and availability.available_qty > 0 if is_available: filtered_cart_items.append(cart_item) else: await CartItemRepository.remove_from_cart(cart_item.id, session) await session_commit(session) + subcategory_map = { + subcategory.id: subcategory + for subcategory in await SubcategoryRepository.get_by_ids( + [cart_item.subcategory_id for cart_item in filtered_cart_items], + session + ) + } for cart_item in filtered_cart_items: - item = await ItemRepository.get_single(cart_item.item_type, - cart_item.category_id, - cart_item.subcategory_id, - session) - subcategory = await SubcategoryRepository.get_by_id(cart_item.subcategory_id, session) + availability = availability_map[(cart_item.item_type, cart_item.category_id, cart_item.subcategory_id)] + subcategory = subcategory_map[cart_item.subcategory_id] kb_builder.button( text=get_text(language, BotEntity.USER, "cart_item_button").format( subcategory_name=subcategory.name, qty=cart_item.quantity, - total_price=cart_item.quantity * item.price, + total_price=cart_item.quantity * availability.price, currency_sym=config.CURRENCY.get_localized_symbol() ), callback_data=CartCallback.create( @@ -183,6 +192,7 @@ async def checkout_processing(callback: CallbackQuery, language: Language) -> tuple[str, InlineKeyboardBuilder]: user = await UserRepository.get_by_tgid(callback.from_user.id, session) cart_items = await CartItemRepository.get_all_by_user_id(user.id, session) + availability_map = await CartService._get_cart_availability_map(cart_items, session) cart_items_dict = {} for cart_item in cart_items: if cart_items_dict.get(cart_item.item_type) is None: @@ -205,15 +215,19 @@ async def checkout_processing(callback: CallbackQuery, cart_total_price += shipping_option.price else: shipping_option = None + subcategory_map = { + subcategory.id: subcategory + for subcategory in await SubcategoryRepository.get_by_ids( + [cart_item.subcategory_id for cart_item in cart_items], + session + ) + } for item_type, cart_items in cart_items_dict.items(): cart_content.append(item_type.get_localized(language)) for cart_item in cart_items: - item = await ItemRepository.get_single(item_type=cart_item.item_type, - category_id=cart_item.category_id, - subcategory_id=cart_item.subcategory_id, - session=session) - subcategory = await SubcategoryRepository.get_by_id(cart_item.subcategory_id, session) - line_item_total = item.price * cart_item.quantity + availability = availability_map[(cart_item.item_type, cart_item.category_id, cart_item.subcategory_id)] + subcategory = subcategory_map[cart_item.subcategory_id] + line_item_total = availability.price * cart_item.quantity cart_content.append( get_text(language, BotEntity.USER, "cart_item_button").format( subcategory_name=subcategory.name, @@ -290,6 +304,7 @@ async def buy_processing(callback: CallbackQuery, language: Language) -> tuple[str, InlineKeyboardBuilder]: user = await UserRepository.get_by_tgid(callback.from_user.id, session) cart_items = await CartItemRepository.get_all_by_user_id(user.id, session) + availability_map = await CartService._get_cart_availability_map(cart_items, session) if callback_data.shipping_option_id: shipping_option = await ShippingOptionRepository.get_by_id(callback_data.shipping_option_id, session) cart_total_price = shipping_option.price @@ -298,15 +313,12 @@ async def buy_processing(callback: CallbackQuery, cart_total_price = 0.0 out_of_stock = [] for cart_item in cart_items: - item = await ItemRepository.get_single(item_type=cart_item.item_type, - category_id=cart_item.category_id, - subcategory_id=cart_item.subcategory_id, - session=session) - cart_total_price += item.price * cart_item.quantity - is_in_stock = await ItemRepository.get_available_qty(item_type=cart_item.item_type, - category_id=cart_item.category_id, - subcategory_id=cart_item.subcategory_id, - session=session) >= cart_item.quantity + availability = availability_map.get((cart_item.item_type, cart_item.category_id, cart_item.subcategory_id)) + if availability is None: + out_of_stock.append(cart_item) + continue + cart_total_price += availability.price * cart_item.quantity + is_in_stock = availability.available_qty >= cart_item.quantity if is_in_stock is False: out_of_stock.append(cart_item) total_discount_amount = 0 @@ -371,9 +383,15 @@ async def buy_processing(callback: CallbackQuery, elif len(out_of_stock) > 0: kb_builder.row(callback_data.get_back_button(language, 0)) msg = get_text(language, BotEntity.USER, "out_of_stock") + subcategory_map = { + subcategory.id: subcategory + for subcategory in await SubcategoryRepository.get_by_ids( + [item.subcategory_id for item in out_of_stock], + session + ) + } for item in out_of_stock: - subcategory = await SubcategoryRepository.get_by_id(item.subcategory_id, session) - msg += subcategory.name + "\n" + msg += f"{subcategory_map[item.subcategory_id].name}\n" return msg, kb_builder @staticmethod @@ -381,10 +399,11 @@ async def show_cart_item(callback_data: CartCallback, session: AsyncSession, language: Language): cart_item_dto = await CartItemRepository.get_by_primary_key(callback_data.cart_item_id, session) - available_qty = await ItemRepository.get_available_qty(cart_item_dto.item_type, - cart_item_dto.category_id, - cart_item_dto.subcategory_id, - session) + availability_map = await CartService._get_cart_availability_map([cart_item_dto], session) + availability = availability_map.get( + (cart_item_dto.item_type, cart_item_dto.category_id, cart_item_dto.subcategory_id) + ) + available_qty = availability.available_qty if availability else 0 if callback_data.cart_action == CartAction.REMOVE_ALL or cart_item_dto.quantity == 0: return await CartService.delete_cart_item(callback_data, session, language) elif callback_data.cart_action in [CartAction.PLUS_ONE, CartAction.MINUS_ONE]: @@ -399,10 +418,7 @@ async def show_cart_item(callback_data: CartCallback, cart_item_dto.quantity = available_qty await CartItemRepository.update(cart_item_dto, session) await session_commit(session) - item_dto = await ItemRepository.get_single(cart_item_dto.item_type, - cart_item_dto.category_id, - cart_item_dto.subcategory_id, - session) + item_dto = availability category = await CategoryRepository.get_by_id(cart_item_dto.category_id, session) subcategory = await SubcategoryRepository.get_by_id(cart_item_dto.subcategory_id, session) cart_actions = [CartAction.REMOVE_ALL, CartAction.MINUS_ONE, CartAction.PLUS_ONE, CartAction.MAX] @@ -458,7 +474,7 @@ async def receive_purchase_details(message: Message, kb_builder.button( text=get_text(language, BotEntity.COMMON, "pagination_next"), callback_data=CartCallback.create(level=2, - shipping_option_id=state_data.get("")) + shipping_option_id=state_data.get("shipping_option_id")) ) if coupon_dto is None: caption = get_text(language, BotEntity.USER, "coupon_not_found") diff --git a/services/item.py b/services/item.py index 9a9743ab..8108f923 100644 --- a/services/item.py +++ b/services/item.py @@ -36,9 +36,23 @@ async def create_announcement_message(announcement_type: AnnouncementType, items = await ItemRepository.get_new(session) header = get_text(language, BotEntity.ADMIN, "restocking_message_header") filtered_items = {} + category_map = { + category.id: category + for category in await CategoryRepository.get_by_ids( + [item.category_id for item in items], + session + ) + } + subcategory_map = { + subcategory.id: subcategory + for subcategory in await SubcategoryRepository.get_by_ids( + [item.subcategory_id for item in items], + session + ) + } for item in items: - category = await CategoryRepository.get_by_id(item.category_id, session) - subcategory = await SubcategoryRepository.get_by_id(item.subcategory_id, session) + category = category_map[item.category_id] + subcategory = subcategory_map[item.subcategory_id] if category.name not in filtered_items: filtered_items[category.name] = {} if subcategory.name not in filtered_items[category.name]: diff --git a/services/multibot.py b/services/multibot.py new file mode 100644 index 00000000..4b81ec89 --- /dev/null +++ b/services/multibot.py @@ -0,0 +1,156 @@ +import asyncio +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 + + +class MultibotService: + TOKENS_REDIS_KEY = "multibot:tokens" + SEND_DELAY_SECONDS = 0.3 + + @staticmethod + def _build_redis_client() -> Redis: + return Redis(host=config.REDIS_HOST, password=config.REDIS_PASSWORD) + + @staticmethod + async def _get_redis_client(redis_client: Redis | None = None) -> tuple[Redis, bool]: + if redis_client is not None: + return redis_client, False + return MultibotService._build_redis_client(), True + + @staticmethod + async def _close_redis_client(redis_client: Redis, should_close: bool) -> None: + if should_close: + await redis_client.close() + + @staticmethod + def build_bot(token: str) -> Bot: + return Bot(token=token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + + @staticmethod + async def get_child_tokens(redis_client: Redis | None = None) -> list[str]: + redis_client, should_close = await MultibotService._get_redis_client(redis_client) + try: + tokens = await redis_client.smembers(MultibotService.TOKENS_REDIS_KEY) + return sorted( + token.decode() if isinstance(token, bytes) else token + for token in tokens + ) + finally: + await MultibotService._close_redis_client(redis_client, should_close) + + @staticmethod + async def get_all_tokens_with_main(redis_client: Redis | None = None) -> list[str]: + tokens = [config.TOKEN] + if config.MULTIBOT: + tokens.extend(await MultibotService.get_child_tokens(redis_client)) + return list(dict.fromkeys(tokens)) + + @staticmethod + async def has_token(token: str, redis_client: Redis | None = None) -> bool: + if token == config.TOKEN: + return True + redis_client, should_close = await MultibotService._get_redis_client(redis_client) + try: + return bool(await redis_client.sismember(MultibotService.TOKENS_REDIS_KEY, token)) + finally: + await MultibotService._close_redis_client(redis_client, should_close) + + @staticmethod + async def add_token(token: str, redis_client: Redis | None = None) -> bool: + if token == config.TOKEN: + return False + redis_client, should_close = await MultibotService._get_redis_client(redis_client) + try: + added = await redis_client.sadd(MultibotService.TOKENS_REDIS_KEY, token) + return bool(added) + finally: + await MultibotService._close_redis_client(redis_client, should_close) + + @staticmethod + async def remove_token(token: str, redis_client: Redis | None = None) -> None: + if token == config.TOKEN: + return + redis_client, should_close = await MultibotService._get_redis_client(redis_client) + try: + await redis_client.srem(MultibotService.TOKENS_REDIS_KEY, token) + finally: + await MultibotService._close_redis_client(redis_client, should_close) + + @staticmethod + async def restore_child_bot_webhooks(webhook_url_template: str, + redis_client: Redis | None = None) -> None: + for token in await MultibotService.get_child_tokens(redis_client): + bot = MultibotService.build_bot(token) + try: + await bot.get_me() + await bot.set_webhook(webhook_url_template.format(bot_token=token)) + except TelegramUnauthorizedError: + logging.warning("Removing unauthorized child bot token during startup recovery") + await MultibotService.remove_token(token, redis_client) + except Exception as exception: + logging.error(exception) + finally: + 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: + success_count = 0 + 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 + except TelegramUnauthorizedError: + logging.warning("Removing unauthorized child bot token during send_message_to_user") + await MultibotService.remove_token(token, redis_client) + except Exception as exception: + logging.error(exception) + finally: + await bot.session.close() + if index < len(tokens) - 1: + await asyncio.sleep(MultibotService.SEND_DELAY_SECONDS) + return success_count + + @staticmethod + async def copy_message_to_user(from_chat_id: int, + message_id: int, + telegram_id: int, + 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.copy_message( + chat_id=telegram_id, + from_chat_id=from_chat_id, + message_id=message_id + ) + 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 copy_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 diff --git a/services/notification.py b/services/notification.py index 7e40e109..5902bf64 100644 --- a/services/notification.py +++ b/services/notification.py @@ -22,6 +22,7 @@ from models.review import ReviewDTO from models.user import UserDTO from models.withdrawal import WithdrawalDTO +from services.multibot import MultibotService from repositories.buyItem import BuyItemRepository from repositories.category import CategoryRepository from repositories.item import ItemRepository @@ -55,6 +56,9 @@ async def send_to_admins(message: str | BufferedInputFile, reply_markup: types.I @staticmethod async def send_to_user(message: str, telegram_id: int, reply_markup: types.InlineKeyboardMarkup | None = None): + 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)) try: await bot.send_message(telegram_id, message, reply_markup=reply_markup) @@ -182,11 +186,29 @@ async def new_buy(buy: BuyDTO, user: UserDTO, session: AsyncSession | Session): admin_kb_builder.adjust(1) cart_content = [] buyItem_list = await BuyItemRepository.get_all_by_buy_id(buy.id, session) + item_map = await ItemRepository.get_by_id_map( + [buy_item.item_ids[0] for buy_item in buyItem_list if buy_item.item_ids], + session + ) + category_map = { + category.id: category + for category in await CategoryRepository.get_by_ids( + [item.category_id for item in item_map.values()], + session + ) + } + subcategory_map = { + subcategory.id: subcategory + for subcategory in await SubcategoryRepository.get_by_ids( + [item.subcategory_id for item in item_map.values()], + session + ) + } currency_sym = config.CURRENCY.get_localized_symbol() for buyItem in buyItem_list: - item_example = await ItemRepository.get_by_id(buyItem.item_ids[0], session) - category = await CategoryRepository.get_by_id(item_example.category_id, session) - subcategory = await SubcategoryRepository.get_by_id(item_example.subcategory_id, session) + item_example = item_map[buyItem.item_ids[0]] + category = category_map[item_example.category_id] + subcategory = subcategory_map[item_example.subcategory_id] if user.telegram_username: msg = get_text(Language.EN, BotEntity.ADMIN, "notification_purchase_with_username") else: @@ -266,8 +288,13 @@ async def withdrawal(withdraw_dto: WithdrawalDTO): async def new_review_published(review_dto: ReviewDTO, session: AsyncSession): kb_builder = InlineKeyboardBuilder() buyItem_dto = await BuyItemRepository.get_by_id(review_dto.buyItem_id, session) - item_dto = await ItemRepository.get_by_id(buyItem_dto.item_ids[0], session) - subcategory_dto = await SubcategoryRepository.get_by_id(item_dto.subcategory_id, session) + item_map = await ItemRepository.get_by_id_map([buyItem_dto.item_ids[0]], session) + item_dto = item_map[buyItem_dto.item_ids[0]] + subcategory_map = { + subcategory.id: subcategory + for subcategory in await SubcategoryRepository.get_by_ids([item_dto.subcategory_id], session) + } + subcategory_dto = subcategory_map[item_dto.subcategory_id] kb_builder.button( text=get_text(Language.EN, BotEntity.USER, "review").format( subcategory_name=subcategory_dto.name diff --git a/services/payment.py b/services/payment.py index f8127621..a27f1ab5 100644 --- a/services/payment.py +++ b/services/payment.py @@ -47,18 +47,7 @@ def __create_qr_code(payment_dto: ProcessingPaymentDTO): @staticmethod async def __create_invoice(payment_dto: ProcessingPaymentDTO) -> ProcessingPaymentDTO: - headers = { - "X-Api-Key": config.KRYPTO_EXPRESS_API_KEY, - "Content-Type": "application/json" - } - payment_dto = await CryptoApiWrapper.fetch_api_request( - f"{config.KRYPTO_EXPRESS_API_URL}/payment", - method="POST", - data=payment_dto.model_dump_json(exclude_none=True), - headers=headers - ) - payment_dto = ProcessingPaymentDTO.model_validate(payment_dto, from_attributes=True) - return payment_dto + return await CryptoApiWrapper.create_invoice(payment_dto) @staticmethod def __request_fiat_amount(kb_builder: InlineKeyboardBuilder, language: Language): diff --git a/services/statistics.py b/services/statistics.py index b8be30df..9f059994 100644 --- a/services/statistics.py +++ b/services/statistics.py @@ -153,7 +153,7 @@ async def get_statistics(callback_data: StatisticsCallback, chart = state_data.get(f"chart_{callback_data.timedelta.name.lower()}") if chart is None: chart = StatisticsService.build_statistics_chart(users, callback_data.timedelta, language) - await state.update_data(chart=chart.hex()) + await state.update_data(**{f"chart_{callback_data.timedelta.name.lower()}": chart.hex()}) else: chart = bytes.fromhex(chart) kb_builder.adjust(1) @@ -178,11 +178,10 @@ async def get_statistics(callback_data: StatisticsCallback, buys = await BuyRepository.get_by_timedelta(callback_data.timedelta, session) chart = StatisticsService.build_statistics_chart(buys, callback_data.timedelta, language) total_profit = 0.0 - items_sold = 0 + buy_items = await BuyItemRepository.get_all_by_buy_ids([buy.id for buy in buys], session) for buy in buys: total_profit += buy.total_price - buyItem_list = await BuyItemRepository.get_all_by_buy_id(buy.id, session) - items_sold += sum([len(buyItem.item_ids) for buyItem in buyItem_list]) + items_sold = sum(len(buy_item.item_ids) for buy_item in buy_items) kb_builder.row(AdminConstants.back_to_main_button(language), callback_data.get_back_button(language)) caption = get_text(language, BotEntity.ADMIN, "sales_statistics").format( timedelta=timedelta_localized, @@ -205,7 +204,6 @@ async def get_statistics(callback_data: StatisticsCallback, crypto_friendly_amount = deposit.amount / pow(10, deposit.network.get_decimals()) crypto_amount_dict[deposit.network] += crypto_friendly_amount fiat_amount += crypto_friendly_amount * crypto_price - fiat_amount = sum(crypto_amount_dict.values()) kb_builder.row(AdminConstants.back_to_main_button(language), callback_data.get_back_button(language)) deposits_content_list = [] for cryptocurrency, crypto_amount in crypto_amount_dict.items(): diff --git a/services/user.py b/services/user.py index 904bedde..26d75971 100644 --- a/services/user.py +++ b/services/user.py @@ -86,7 +86,7 @@ async def get_my_profile_buttons(telegram_id: int, async def get_top_up_buttons(callback_data: MyProfileCallback, language: Language) -> tuple[str, InlineKeyboardBuilder]: kb_builder = InlineKeyboardBuilder() - for cryptocurrency in Cryptocurrency: + for cryptocurrency in Cryptocurrency.get_visible(): kb_builder.button( text=cryptocurrency.get_localized(language), callback_data=MyProfileCallback.create(level=callback_data.level + 1, diff --git a/services/wallet.py b/services/wallet.py index 5f9dc7f2..280053d9 100644 --- a/services/wallet.py +++ b/services/wallet.py @@ -30,6 +30,8 @@ async def get_withdraw_menu(language: Language) -> tuple[str, InlineKeyboardBuil wallet_balance = await CryptoApiWrapper.get_wallet_balance() wallet_content = [] for cryptocurrency, amount in wallet_balance.items(): + if cryptocurrency in Cryptocurrency.get_hidden(): + continue wallet_content.append(get_text(language, BotEntity.ADMIN, "crypto_wallet_line").format( crypto_name=cryptocurrency.name.replace('_', " "), crypto_balance=amount @@ -44,7 +46,8 @@ async def get_withdraw_menu(language: Language) -> tuple[str, InlineKeyboardBuil msg_text = get_text(language, BotEntity.ADMIN, "crypto_wallet").format( wallet_content="\n".join(wallet_content) ) - if sum(wallet_balance.values()) > 0: + if any(amount > 0 for cryptocurrency, amount in wallet_balance.items() + if cryptocurrency not in Cryptocurrency.get_hidden()): msg_text += get_text(language, BotEntity.ADMIN, "choose_crypto_to_withdraw") return msg_text, kb_builder @@ -64,7 +67,8 @@ async def calculate_withdrawal(message: Message, state: FSMContext, language: Language) -> tuple[str, InlineKeyboardBuilder]: kb_builder = InlineKeyboardBuilder() - if message.text and message.text.lower() == "cancel": + cancel_text = get_text(language, BotEntity.COMMON, "cancel") + if message.text and message.text.casefold() == cancel_text.casefold(): await state.clear() return get_text(language, BotEntity.COMMON, "cancelled"), kb_builder to_address = message.text @@ -127,6 +131,7 @@ def validate_withdrawal_address(address: str, cryptocurrency: Cryptocurrency) -> Cryptocurrency.ETH: re.compile(r'^0x[a-fA-F0-9]{40}$'), Cryptocurrency.BNB: re.compile(r'^0x[a-fA-F0-9]{40}$'), Cryptocurrency.SOL: re.compile(r'^[1-9A-HJ-NP-Za-km-z]{32,44}$'), + Cryptocurrency.DOGE: re.compile(r'^[DA9][a-km-zA-HJ-NP-Z1-9]{25,34}$'), Cryptocurrency.USDT_SOL: re.compile(r'^[1-9A-HJ-NP-Za-km-z]{32,44}$'), Cryptocurrency.USDC_SOL: re.compile(r'^[1-9A-HJ-NP-Za-km-z]{32,44}$'), Cryptocurrency.USDT_ERC20: re.compile(r'^0x[a-fA-F0-9]{40}$'), diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..3abb0a43 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,119 @@ +import sys +from types import ModuleType, SimpleNamespace + + +sys.path = [ + path for path in sys.path + if "AiogramShopBot\\Lib" not in path and "AiogramShopBot/Lib" not in path +] + + +def _build_config_module() -> ModuleType: + config = ModuleType("config") + currency = SimpleNamespace( + value="USD", + get_localized_text=lambda: "USD", + get_localized_symbol=lambda: "$", + ) + config.PAGE_ENTRIES = 8 + config.WEBHOOK_URL = "https://example.com/" + config.KRYPTO_EXPRESS_API_KEY = "test-api-key" + config.KRYPTO_EXPRESS_API_URL = "https://kryptoexpress.pro/api" + config.KRYPTO_EXPRESS_API_SECRET = "test-secret" + config.WEBHOOK_HOST = "https://example.com" + config.WEBAPP_HOST = "127.0.0.1" + config.WEBAPP_PORT = 5000 + config.CRYPTO_FORWARDING_MODE = False + config.BTC_FORWARDING_ADDRESS = "btc-forward" + config.LTC_FORWARDING_ADDRESS = "ltc-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.REDIS_HOST = "localhost" + config.REDIS_PASSWORD = "password" + config.CURRENCY = currency + config.DB_USER = "postgres" + config.DB_PASS = "postgres" + config.DB_HOST = "localhost" + config.DB_PORT = 5432 + config.DB_NAME = "test" + config.JWT_EXPIRE_MINUTES = 30 + config.JWT_SECRET_KEY = "secret" + config.JWT_ALGORITHM = "HS256" + return config + + +sys.modules.setdefault("config", _build_config_module()) + +sqladmin_module = ModuleType("sqladmin") +sqladmin_module.ModelView = type("ModelView", (), {"__init_subclass__": classmethod(lambda cls, **kwargs: None)}) +sys.modules.setdefault("sqladmin", sqladmin_module) + +jose_module = ModuleType("jose") +jose_module.JWTError = Exception +jose_module.jwt = SimpleNamespace( + encode=lambda payload, secret, algorithm=None: "token", + decode=lambda token, secret, algorithms=None: {"sub": "test"}, +) +sys.modules.setdefault("jose", jose_module) + +passlib_module = ModuleType("passlib") +passlib_context_module = ModuleType("passlib.context") + + +class _CryptContext: + def __init__(self, *args, **kwargs): + pass + + def verify(self, plain_password: str, hashed_password: str) -> bool: + return plain_password == hashed_password + + def hash(self, plain_password: str) -> str: + return f"hashed:{plain_password}" + + +passlib_context_module.CryptContext = _CryptContext +passlib_module.context = passlib_context_module +sys.modules.setdefault("passlib", passlib_module) +sys.modules.setdefault("passlib.context", passlib_context_module) + +pyngrok_module = ModuleType("pyngrok") +pyngrok_module.ngrok = SimpleNamespace( + set_auth_token=lambda token: None, + connect=lambda *args, **kwargs: SimpleNamespace(public_url="https://example.ngrok"), +) +sys.modules.setdefault("pyngrok", pyngrok_module) + +redis_module = ModuleType("redis") +redis_asyncio_module = ModuleType("redis.asyncio") + + +class _Redis: + def __init__(self, *args, **kwargs): + pass + + async def close(self): + return None + + +redis_asyncio_module.Redis = _Redis +redis_module.asyncio = redis_asyncio_module +sys.modules.setdefault("redis", redis_module) +sys.modules.setdefault("redis.asyncio", redis_asyncio_module) + +db_module = ModuleType("db") + + +async def _noop_async(*args, **kwargs): + return None + + +db_module.session_execute = _noop_async +db_module.session_flush = _noop_async +db_module.session_commit = _noop_async +db_module.get_db_session = _noop_async +db_module.create_db_and_tables = _noop_async +sys.modules.setdefault("db", db_module) diff --git a/tests/test_crypto_api_wrapper.py b/tests/test_crypto_api_wrapper.py new file mode 100644 index 00000000..6b53544f --- /dev/null +++ b/tests/test_crypto_api_wrapper.py @@ -0,0 +1,117 @@ +import pytest + +from crypto_api.CryptoApiWrapper import CryptoApiWrapper +from enums.cryptocurrency import Cryptocurrency +from enums.currency import Currency +from enums.payment import PaymentType +from models.payment import ProcessingPaymentDTO + + +class _FakePaymentsResource: + def __init__(self): + self.created_payment = None + self.created_deposit = None + + async def create_payment(self, **kwargs): + self.created_payment = kwargs + return type("Payment", (), { + "id": 11, + "payment_type": type("PaymentType", (), {"value": "PAYMENT"})(), + "fiat_currency": type("FiatCurrency", (), {"value": "USD"})(), + "fiat_amount": 15.0, + "crypto_amount": 0.001, + "crypto_currency": type("CryptoCurrency", (), {"value": "BTC"})(), + "expire_datetime": 1, + "create_datetime": 2, + "address": "btc-address", + "is_paid": False, + "is_withdrawn": False, + "hash": "hash", + "callback_url": "https://example.com/callback", + })() + + async def create_deposit(self, **kwargs): + self.created_deposit = kwargs + return type("Payment", (), { + "id": 12, + "payment_type": type("PaymentType", (), {"value": "DEPOSIT"})(), + "fiat_currency": type("FiatCurrency", (), {"value": "USD"})(), + "fiat_amount": None, + "crypto_amount": None, + "crypto_currency": type("CryptoCurrency", (), {"value": "BTC"})(), + "expire_datetime": 1, + "create_datetime": 2, + "address": "btc-address", + "is_paid": False, + "is_withdrawn": False, + "hash": "hash", + "callback_url": "https://example.com/callback", + })() + + +class _FakeWalletResource: + def __init__(self): + self.last_call = None + + async def calculate_single(self, **kwargs): + self.last_call = ("calculate_single", kwargs) + return type("Withdrawal", (), { + "withdraw_type": type("WithdrawType", (), {"value": "SINGLE"})(), + "crypto_currency": type("CryptoCurrency", (), {"value": "BTC"})(), + "to_address": kwargs["to_address"], + "tx_id_list": [], + "receiving_amount": 0.9, + "blockchain_fee_amount": 0.1, + "service_fee_amount": 0.0, + "only_calculate": True, + "total_withdrawal_amount": 1.0, + "payment_id": kwargs["payment_id"], + })() + + +class _FakeClient: + def __init__(self): + self.payments = _FakePaymentsResource() + self.wallet = _FakeWalletResource() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return None + + +@pytest.mark.asyncio +async def test_create_invoice_uses_sdk_payment_resource(monkeypatch): + fake_client = _FakeClient() + monkeypatch.setattr("crypto_api.CryptoApiWrapper.CryptoApiWrapper._build_client", lambda: fake_client) + + payment = await CryptoApiWrapper.create_invoice(ProcessingPaymentDTO( + paymentType=PaymentType.PAYMENT, + fiatCurrency=Currency.USD, + fiatAmount=15.0, + cryptoCurrency=Cryptocurrency.BTC, + callbackUrl="https://example.com/callback", + callbackSecret="secret", + )) + + assert payment.id == 11 + assert fake_client.payments.created_payment["fiat_amount"] == 15.0 + assert fake_client.payments.created_payment["crypto_currency"].value == "BTC" + + +@pytest.mark.asyncio +async def test_withdrawal_uses_sdk_calculate_single(monkeypatch): + fake_client = _FakeClient() + monkeypatch.setattr("crypto_api.CryptoApiWrapper.CryptoApiWrapper._build_client", lambda: fake_client) + + withdrawal = await CryptoApiWrapper.withdrawal( + cryptocurrency=Cryptocurrency.BTC, + to_address="bc1destination", + only_calculate=True, + payment_id=99, + ) + + assert withdrawal.withdrawType.value == "SINGLE" + assert fake_client.wallet.last_call[0] == "calculate_single" + assert fake_client.wallet.last_call[1]["payment_id"] == 99 diff --git a/tests/test_localizator.py b/tests/test_localizator.py new file mode 100644 index 00000000..47563e31 --- /dev/null +++ b/tests/test_localizator.py @@ -0,0 +1,13 @@ +from enums.bot_entity import BotEntity +from enums.language import Language +from utils.localizator import Localizator + + +def test_localizator_returns_text_from_json(): + assert Localizator.get_text(Language.EN, BotEntity.COMMON, "cancel") == "❌ Cancel" + + +def test_localizator_collects_all_localized_values(): + localized = Localizator.get_all_texts(BotEntity.COMMON, "cancel") + assert "❌ Cancel" in localized + assert len(localized) >= 2 diff --git a/tests/test_multibot.py b/tests/test_multibot.py new file mode 100644 index 00000000..745d22a1 --- /dev/null +++ b/tests/test_multibot.py @@ -0,0 +1,352 @@ +from types import SimpleNamespace + +import pytest +from aiogram.exceptions import TelegramUnauthorizedError + +import config +from enums.announcement_type import AnnouncementType +from enums.language import Language +from models.user import UserDTO +from multibot import command_add_bot, on_startup +from services.announcement import AnnouncementService +from services.multibot import MultibotService +from services.notification import NotificationService + + +class _FakeRedis: + def __init__(self): + self.values = set() + self.closed = False + + async def smembers(self, key): + return set(self.values) + + async def sismember(self, key, value): + return value in self.values + + async def sadd(self, key, value): + initial_len = len(self.values) + self.values.add(value) + return 1 if len(self.values) != initial_len else 0 + + async def srem(self, key, value): + self.values.discard(value) + return 1 + + async def close(self): + self.closed = True + + +class _FakeSession: + async def close(self): + return None + + +class _FakeBot: + def __init__(self, token, behavior=None): + self.token = token + self.behavior = behavior or {} + self.session = _FakeSession() + self.sent_messages = [] + self.copied_messages = [] + self.webhooks = [] + + async def get_me(self): + action = self.behavior.get("get_me") + if isinstance(action, Exception): + raise action + username = self.behavior.get("username", "child_bot") + return SimpleNamespace(username=username) + + async def set_webhook(self, url, **kwargs): + action = self.behavior.get("set_webhook") + if isinstance(action, Exception): + raise action + self.webhooks.append((url, kwargs)) + + async def delete_webhook(self, **kwargs): + self.webhooks.append(("delete", kwargs)) + + async def send_message(self, chat_id, text, reply_markup=None): + action = self.behavior.get("send_message") + if isinstance(action, Exception): + raise action + self.sent_messages.append((chat_id, text, reply_markup)) + + async def copy_message(self, chat_id, from_chat_id, message_id): + action = self.behavior.get("copy_message") + if isinstance(action, Exception): + raise action + self.copied_messages.append((chat_id, from_chat_id, message_id)) + + +class _FakeMessage: + def __init__(self): + self.answers = [] + self.chat = SimpleNamespace(id=77) + self.message_id = 501 + + async def answer(self, text): + self.answers.append(text) + return SimpleNamespace(message_id=900, chat=SimpleNamespace(id=123)) + + async def edit_reply_markup(self): + return None + + +class _FakeCallback: + def __init__(self): + self.message = _FakeMessage() + + +@pytest.mark.asyncio +async def test_multibot_token_storage_ignores_main_token(): + redis = _FakeRedis() + + assert await MultibotService.add_token("child-token", redis) is True + assert await MultibotService.add_token("child-token", redis) is False + assert await MultibotService.add_token(config.TOKEN, redis) is False + assert await MultibotService.get_child_tokens(redis) == ["child-token"] + + await MultibotService.remove_token("child-token", redis) + + assert await MultibotService.get_child_tokens(redis) == [] + + +@pytest.mark.asyncio +async def test_command_add_bot_rejects_main_token(monkeypatch): + message = _FakeMessage() + parent_bot = SimpleNamespace(session=_FakeSession()) + monkeypatch.setattr("multibot.is_bot_token", lambda token: True) + + await command_add_bot(message, SimpleNamespace(args=config.TOKEN), parent_bot) + + assert "main bot token" in message.answers[-1].lower() + + +@pytest.mark.asyncio +async def test_command_add_bot_rejects_duplicate(monkeypatch): + message = _FakeMessage() + parent_bot = SimpleNamespace(session=_FakeSession()) + monkeypatch.setattr("multibot.is_bot_token", lambda token: True) + + async def _fake_has_token(token): + return True + + monkeypatch.setattr("multibot.MultibotService.has_token", _fake_has_token) + + await command_add_bot(message, SimpleNamespace(args="123:abc"), parent_bot) + + assert "already" in message.answers[-1].lower() + + +@pytest.mark.asyncio +async def test_command_add_bot_saves_valid_token(monkeypatch): + message = _FakeMessage() + parent_bot = SimpleNamespace(session=_FakeSession()) + added_tokens = [] + created_bots = [] + + async def _fake_has_token(token): + return False + + async def _fake_add_token(token): + added_tokens.append(token) + return True + + def _fake_bot_factory(token, session): + bot = _FakeBot(token, behavior={"username": "shop_child"}) + bot.session = session + created_bots.append(bot) + return bot + + monkeypatch.setattr("multibot.is_bot_token", lambda token: True) + monkeypatch.setattr("multibot.MultibotService.has_token", _fake_has_token) + monkeypatch.setattr("multibot.MultibotService.add_token", _fake_add_token) + monkeypatch.setattr("multibot.Bot", _fake_bot_factory) + + await command_add_bot(message, SimpleNamespace(args="123456:ABCDEF"), parent_bot) + + assert added_tokens == ["123456:ABCDEF"] + assert "shop_child" in message.answers[-1] + assert created_bots[0].webhooks[-1][0].endswith("/webhook/bot/123456:ABCDEF") + + +@pytest.mark.asyncio +async def test_restore_child_bot_webhooks_removes_unauthorized_token(monkeypatch): + created_bots = {} + removed_tokens = [] + + async def _fake_get_child_tokens(redis_client=None): + return ["good-token", "bad-token"] + + async def _fake_remove_token(token, redis_client=None): + removed_tokens.append(token) + + def _fake_build_bot(token): + behavior = {"username": "restored"} + if token == "bad-token": + behavior["get_me"] = TelegramUnauthorizedError( + method=SimpleNamespace(__api_method__="getMe"), + message="unauthorized" + ) + bot = _FakeBot(token, behavior=behavior) + created_bots[token] = bot + return bot + + monkeypatch.setattr("services.multibot.MultibotService.get_child_tokens", _fake_get_child_tokens) + monkeypatch.setattr("services.multibot.MultibotService.remove_token", _fake_remove_token) + monkeypatch.setattr("services.multibot.MultibotService.build_bot", _fake_build_bot) + + await MultibotService.restore_child_bot_webhooks("https://example.com/webhook/bot/{bot_token}") + + assert created_bots["good-token"].webhooks[-1][0].endswith("/good-token") + assert removed_tokens == ["bad-token"] + + +@pytest.mark.asyncio +async def test_send_to_user_multibot_uses_all_tokens_and_sleep(monkeypatch): + sleep_calls = [] + built_bots = [] + + async def _fake_get_all_tokens(redis_client=None): + return ["main-token", "child-token"] + + async def _fake_sleep(delay): + sleep_calls.append(delay) + + def _fake_build_bot(token): + bot = _FakeBot(token) + built_bots.append(bot) + return bot + + monkeypatch.setattr("services.multibot.MultibotService.get_all_tokens_with_main", _fake_get_all_tokens) + monkeypatch.setattr("services.multibot.MultibotService.build_bot", _fake_build_bot) + monkeypatch.setattr("services.multibot.asyncio.sleep", _fake_sleep) + + sent_count = await MultibotService.send_message_to_user("hello", 100500) + + assert sent_count == 2 + assert [bot.token for bot in built_bots] == ["main-token", "child-token"] + assert sleep_calls == [0.3] + + +@pytest.mark.asyncio +async def test_notification_service_send_to_user_uses_multibot_service(monkeypatch): + called = [] + monkeypatch.setattr("config.MULTIBOT", True) + + async def _fake_send_message_to_user(message, telegram_id, reply_markup=None): + called.append((message, telegram_id, reply_markup)) + return 2 + + monkeypatch.setattr("services.notification.MultibotService.send_message_to_user", _fake_send_message_to_user) + + await NotificationService.send_to_user("hello", 42) + + assert called == [("hello", 42, None)] + + +@pytest.mark.asyncio +async def test_announcement_service_counts_multibot_successes(monkeypatch): + callback = _FakeCallback() + updates = [] + user = UserDTO(telegram_id=777, can_receive_messages=True) + + monkeypatch.setattr("config.MULTIBOT", True) + + async def _fake_get_active(session): + return [user] + + async def _fake_get_all_count(session): + return 1 + + async def _fake_copy_message(from_chat_id, message_id, telegram_id): + return 2, False + + async def _fake_edit_message(message, source_message_id, chat_id): + updates.append(message) + + 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.MultibotService.copy_message_to_user", _fake_copy_message) + 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.CURRENT_STOCK), + object(), + Language.EN + ) + + assert any("2" in message for message in updates) + + +@pytest.mark.asyncio +async def test_announcement_service_marks_user_inactive_when_all_multibot_attempts_forbidden(monkeypatch): + callback = _FakeCallback() + updated_users = [] + user = UserDTO(telegram_id=777, can_receive_messages=True) + + monkeypatch.setattr("config.MULTIBOT", True) + + async def _fake_get_active(session): + return [user] + + async def _fake_get_all_count(session): + return 1 + + async def _fake_copy_message(from_chat_id, message_id, telegram_id): + return 0, True + + async def _fake_update(user_dto, session): + updated_users.append(user_dto) + + 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.MultibotService.copy_message_to_user", _fake_copy_message) + 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) + + await AnnouncementService.send_announcement( + callback, + SimpleNamespace(announcement_type=AnnouncementType.CURRENT_STOCK), + object(), + Language.EN + ) + + assert updated_users + assert updated_users[0].can_receive_messages is False + + +@pytest.mark.asyncio +async def test_multibot_startup_restores_child_webhooks(monkeypatch): + restored = [] + created = [] + + async def _fake_create_db_and_tables(): + created.append(True) + + async def _fake_restore(url): + restored.append(url) + + bot = _FakeBot("main-token") + + monkeypatch.setattr("multibot.create_db_and_tables", _fake_create_db_and_tables) + monkeypatch.setattr("multibot.MultibotService.restore_child_bot_webhooks", _fake_restore) + + await on_startup(SimpleNamespace(), bot) + + assert created == [True] + assert restored == ["https://example.com/webhook/bot/{bot_token}"] diff --git a/tests/test_user_repository.py b/tests/test_user_repository.py new file mode 100644 index 00000000..a6784bf5 --- /dev/null +++ b/tests/test_user_repository.py @@ -0,0 +1,41 @@ +import pytest + +from enums.language import Language +from models.user import UserDTO +from repositories.user import UserRepository + + +class _ScalarResult: + def __init__(self, value): + self._value = value + + def scalar_one_or_none(self): + return self._value + + +class _UserOrm: + id = 7 + telegram_username = "tester" + telegram_id = 123456 + top_up_amount = 0.0 + consume_records = 0.0 + registered_at = None + can_receive_messages = True + language = Language.EN + is_banned = False + referral_code = "REF123" + referred_by_user_id = None + referred_at = None + + +@pytest.mark.asyncio +async def test_get_by_referrer_code_returns_dto(monkeypatch): + async def fake_session_execute(stmt, session): + return _ScalarResult(_UserOrm()) + + monkeypatch.setattr("repositories.user.session_execute", fake_session_execute) + + user = await UserRepository.get_by_referrer_code("REF123", session=None) + + assert isinstance(user, UserDTO) + assert user.referral_code == "REF123" diff --git a/tests/test_wallet_service.py b/tests/test_wallet_service.py new file mode 100644 index 00000000..e4103847 --- /dev/null +++ b/tests/test_wallet_service.py @@ -0,0 +1,88 @@ +import pytest + +from callbacks import MyProfileCallback +from enums.bot_entity import BotEntity +from enums.cryptocurrency import Cryptocurrency +from enums.language import Language +from services.user import UserService +from services.wallet import WalletService +from utils.utils import get_text + + +class _State: + def __init__(self): + self.cleared = False + + async def clear(self): + self.cleared = True + + async def get_data(self): + return {"cryptocurrency": Cryptocurrency.BTC.value} + + async def update_data(self, **kwargs): + return None + + +class _Message: + def __init__(self, text: str): + self.text = text + + +@pytest.mark.asyncio +async def test_wallet_cancel_uses_localized_text(): + state = _State() + message = _Message(get_text(Language.EN, BotEntity.COMMON, "cancel")) + + text, _ = await WalletService.calculate_withdrawal(message, state, Language.EN) + + assert state.cleared is True + assert "Cancelled" in text + + +def test_wallet_validates_new_currency_addresses(): + assert WalletService.validate_withdrawal_address("D8BFXqDM7MHf3A4j3kC8wWEN8DqRLVQjax", Cryptocurrency.DOGE) + + +@pytest.mark.asyncio +async def test_top_up_buttons_include_supported_currencies(): + _, keyboard = await UserService.get_top_up_buttons( + MyProfileCallback.create(level=1), + Language.EN + ) + + button_texts = [ + button.text + for row in keyboard.as_markup().inline_keyboard + for button in row + if getattr(button, "text", None) + ] + + assert "₿ BTC" in button_texts + assert "USDT ERC-20" in button_texts + + +@pytest.mark.asyncio +async def test_admin_withdraw_menu_shows_supported_currencies(monkeypatch): + async def _fake_wallet_balance(): + return { + Cryptocurrency.BTC: 1.25, + Cryptocurrency.USDT_ERC20: 20, + } + + monkeypatch.setattr( + "services.wallet.CryptoApiWrapper.get_wallet_balance", + _fake_wallet_balance + ) + + text, keyboard = await WalletService.get_withdraw_menu(Language.EN) + button_texts = [ + button.text + for row in keyboard.as_markup().inline_keyboard + for button in row + if getattr(button, "text", None) + ] + + assert "BTC" in text + assert "USDT ERC20" in text + assert "₿ BTC" in button_texts + assert "USDT ERC-20" in button_texts diff --git a/utils/localizator.py b/utils/localizator.py new file mode 100644 index 00000000..a35820d0 --- /dev/null +++ b/utils/localizator.py @@ -0,0 +1,46 @@ +import json +from pathlib import Path + +from enums.bot_entity import BotEntity +from enums.language import Language + + +class Localizator: + _i18n_dir = Path("./i18n") + _cache: dict[Language, dict] = {} + + @classmethod + def _load_language(cls, language: Language) -> dict: + cached = cls._cache.get(language) + if cached is not None: + return cached + path = cls._i18n_dir / f"{language.value}.json" + with path.open("r", encoding="utf-8") as file: + data = json.load(file) + cls._cache[language] = data + return data + + @classmethod + def get_text(cls, language: Language, entity: BotEntity, key: str) -> str: + try: + language_payload = cls._load_language(language) + return language_payload[entity.name.lower()][key] + except KeyError: + if language == Language.EN: + raise + return cls.get_text(Language.EN, entity, key) + + @classmethod + def get_all_texts(cls, entity: BotEntity, key: str) -> set[str]: + localized_texts = set() + for locale_file in cls._i18n_dir.glob("*.json"): + try: + language = Language(locale_file.stem) + except ValueError: + continue + localized_texts.add(cls.get_text(language, entity, key)) + return localized_texts + + @classmethod + def clear_cache(cls) -> None: + cls._cache.clear() diff --git a/utils/utils.py b/utils/utils.py index 04b8ce1c..77df3fb7 100644 --- a/utils/utils.py +++ b/utils/utils.py @@ -14,6 +14,7 @@ import config from enums.bot_entity import BotEntity from enums.language import Language +from utils.localizator import Localizator pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") @@ -40,11 +41,12 @@ def start_ngrok(): def get_text(language: Language, entity: BotEntity, key: str) -> str: try: - with open(f"./i18n/{language.value}.json", "r", encoding="UTF-8") as f: - return json.loads(f.read())[entity.name.lower()][key] + return Localizator.get_text(language, entity, key) except Exception as e: logging.error(e) - return get_text(Language.EN, entity, key) + if language == Language.EN: + raise + return Localizator.get_text(Language.EN, entity, key) def remove_html_tags(text: str): From cd146ab89fec50fdd40e5f121f6f0c67b1f94a0b Mon Sep 17 00:00:00 2001 From: ilyarolf Date: Tue, 14 Apr 2026 22:43:57 +0300 Subject: [PATCH 3/6] Trim requirements to direct project dependencies --- requirements.txt | 67 +++--------------------------------------------- 1 file changed, 3 insertions(+), 64 deletions(-) diff --git a/requirements.txt b/requirements.txt index f2fc96f3..af5fb3c1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,84 +1,23 @@ -aiofiles==24.1.0 aiogram==3.14.0 -aiohappyeyeballs==2.4.3 -aiohttp==3.10.11 -aiosignal==1.3.1 -aiosqlite==0.20.0 alembic==1.14.0 -annotated-types==0.7.0 -anyio==4.9.0 -argon2-cffi==25.1.0 -argon2-cffi-bindings==25.1.0 -asn1crypto==1.5.1 asyncpg==0.31.0 -attrs==24.2.0 -bip-utils==2.9.3 -cbor2==5.6.5 -certifi==2024.8.30 -cffi==2.0.0 -charset-normalizer==3.4.0 -click==8.1.8 -coincurve==20.0.0 -colorama==0.4.6 -contourpy==1.3.3 -crcmod==1.7 -cryptography==46.0.3 -cycler==0.12.1 -ecdsa==0.19.0 -ed25519-blake2b==1.4.1 fastapi==0.115.12 -fonttools==4.61.1 -frozenlist==1.5.0 -gevent==24.11.1 -greenlet==3.1.1 -grequests==0.7.0 -h11==0.16.0 httpx==0.28.1 -idna==3.10 -itsdangerous==2.2.0 -Jinja2==3.1.6 -kiwisolver==1.4.9 kryptoexpress-sdk==0.1.1 -magic-filter==1.0.12 -Mako==1.3.10 -MarkupSafe==3.0.3 matplotlib==3.10.7 -multidict==6.1.0 -numpy==2.4.1 -packaging==25.0 passlib==1.7.4 pillow==12.1.0 -propcache==0.2.0 -py-sr25519-bindings==0.2.0 -pyasn1==0.6.1 -pycparser==2.22 -pycryptodome==3.21.0 pydantic==2.9.2 -pydantic_core==2.23.4 -PyNaCl==1.5.0 pyngrok==7.2.1 -pyparsing==3.3.1 -python-dateutil==2.9.0.post0 python-dotenv==1.0.1 python-jose==3.5.0 python-multipart==0.0.21 -PyYAML==6.0.2 qrcode==8.2 redis==6.0.0 -requests==2.32.3 -rsa==4.9.1 -setuptools==75.5.0 -six==1.16.0 -sniffio==1.3.1 sqladmin==0.22.0 SQLAlchemy==2.0.36 -starlette==0.46.2 +uvicorn==0.34.2 + +# Test dependencies pytest==8.4.2 pytest-asyncio==1.2.0 -typing_extensions==4.12.2 -urllib3==2.2.3 -uvicorn==0.34.2 -WTForms==3.1.2 -yarl==1.17.1 -zope.event==5.0 -zope.interface==7.1.1 From 97807ea971c9c00d9030b0cbc1196674b845149e Mon Sep 17 00:00:00 2001 From: ilyarolf Date: Tue, 14 Apr 2026 23:41:08 +0300 Subject: [PATCH 4/6] Align KryptoExpress client with latest fiat validation --- crypto_api/CryptoApiWrapper.py | 21 --------------------- requirements.txt | 1 - 2 files changed, 22 deletions(-) diff --git a/crypto_api/CryptoApiWrapper.py b/crypto_api/CryptoApiWrapper.py index 4b115a9b..36612c7a 100644 --- a/crypto_api/CryptoApiWrapper.py +++ b/crypto_api/CryptoApiWrapper.py @@ -1,4 +1,3 @@ -import httpx import config from kryptoexpress import AsyncKryptoExpressClient, verify_callback_signature from kryptoexpress.models import FiatCurrency as KryptoExpressFiatCurrency @@ -24,31 +23,11 @@ def _map_crypto_from_sdk(cryptocurrency: KryptoExpressCryptoCurrency) -> Cryptoc def _map_fiat_to_sdk(currency: Currency) -> KryptoExpressFiatCurrency: return KryptoExpressFiatCurrency(currency.value) - @staticmethod - async def _convert_fiat_amount(amount: float, - from_currency: KryptoExpressFiatCurrency, - to_currency: KryptoExpressFiatCurrency) -> float: - if from_currency == to_currency: - return amount - async with httpx.AsyncClient(timeout=10.0) as client: - response = await client.get( - "https://api.frankfurter.app/latest", - params={ - "amount": amount, - "from": from_currency.value, - "to": to_currency.value, - }, - ) - response.raise_for_status() - payload = response.json() - return payload["rates"][to_currency.value] - @staticmethod def _build_client() -> AsyncKryptoExpressClient: return AsyncKryptoExpressClient( api_key=config.KRYPTO_EXPRESS_API_KEY, base_url=config.KRYPTO_EXPRESS_API_URL, - async_fiat_converter=CryptoApiWrapper._convert_fiat_amount, ) @staticmethod diff --git a/requirements.txt b/requirements.txt index af5fb3c1..1bcd65fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ aiogram==3.14.0 alembic==1.14.0 asyncpg==0.31.0 fastapi==0.115.12 -httpx==0.28.1 kryptoexpress-sdk==0.1.1 matplotlib==3.10.7 passlib==1.7.4 From 42b5f517cd95c2040df30a5f3142eea624782d51 Mon Sep 17 00:00:00 2001 From: ilyarolf Date: Tue, 14 Apr 2026 23:55:57 +0300 Subject: [PATCH 5/6] Add GitHub community health files and star history --- .github/ISSUE_TEMPLATE/bug_report.md | 36 ++++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 ++++ .github/ISSUE_TEMPLATE/feature_request.md | 23 ++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 18 ++++++++ CODE_OF_CONDUCT.md | 33 +++++++++++++++ CONTRIBUTING.md | 51 +++++++++++++++++++++++ SECURITY.md | 34 +++++++++++++++ SUPPORT.md | 17 ++++++++ readme.md | 11 +++++ 9 files changed, 231 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..2a6f03b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Report a reproducible bug in AiogramShopBot +title: "[Bug] " +labels: bug +assignees: "" +--- + +## Summary + +Describe the bug clearly. + +## Steps to Reproduce + +1. +2. +3. + +## Expected Behavior + +What should happen? + +## Actual Behavior + +What happened instead? + +## Environment + +- Python version: +- Runtime mode: +- Database: +- Redis: + +## Logs / Screenshots + +Add any useful logs, traces, or screenshots. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..ba41c815 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Security report + url: https://t.me/ilyarolf_dev + about: Please report security issues privately. + - name: Documentation + url: https://github.com/ilyarolf/AiogramShopBot/blob/master/docs.md + about: Check the docs before opening a support request. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..98c04357 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an improvement for AiogramShopBot +title: "[Feature] " +labels: enhancement +assignees: "" +--- + +## Problem + +What problem are you trying to solve? + +## Proposed Solution + +Describe the change you want. + +## Alternatives + +Describe any alternative solutions or workarounds. + +## Additional Context + +Add examples, screenshots, or references if helpful. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..b594640c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## Summary + +Describe what changed and why. + +## Changes + +- + +## Verification + +- [ ] I ran relevant tests +- [ ] I updated docs if needed +- [ ] I kept the change focused +- [ ] I checked affected bot/admin/payment flows + +## Notes + +Anything reviewers should pay special attention to: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..2e8290dc --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,33 @@ +# Code of Conduct + +## Our Pledge + +We want AiogramShopBot to be a welcoming, respectful, and useful open source project for everyone. +Contributors, maintainers, and community members are expected to act professionally and constructively in all project spaces. + +## Expected Behavior + +- Be respectful and patient. +- Assume good intent and communicate clearly. +- Focus on technical feedback, not personal attacks. +- Welcome different levels of experience. +- Help keep discussions actionable and on topic. + +## Unacceptable Behavior + +- Harassment, intimidation, or discrimination of any kind. +- Insults, trolling, or sustained hostile behavior. +- Publishing private information without permission. +- Spam, deliberate disruption, or bad-faith reporting. + +## Enforcement + +Project maintainers may remove, edit, or reject comments, issues, pull requests, and other contributions that violate this Code of Conduct. + +## Reporting + +If you experience or witness unacceptable behavior, report it privately: + +- Telegram: [@ilyarolf_dev](https://t.me/ilyarolf_dev) + +All reports will be reviewed reasonably and handled with discretion. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..50941349 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing to AiogramShopBot + +Thanks for your interest in improving AiogramShopBot. + +## Before You Start + +- Read [README.md](readme.md) and [docs.md](docs.md). +- Open an issue first for large changes, architectural changes, or behavior changes. +- Keep changes incremental and easy to review. + +## Development Setup + +```bash +git clone https://github.com/ilyarolf/AiogramShopBot.git +cd AiogramShopBot +pip install -r requirements.txt +python run.py +``` + +You will also need PostgreSQL, Redis, and environment variables configured. + +## Contribution Guidelines + +- Preserve existing architecture and naming style. +- Avoid broad refactors unless they are necessary. +- Keep payment, balance, invoice, callback, and order logic conservative. +- Update JSON localization files for user-facing text changes. +- Add or update tests for changed behavior. +- Keep pull requests focused. + +## Pull Request Checklist + +- The change is scoped and reviewable. +- Tests were added or updated when appropriate. +- Documentation was updated if behavior changed. +- No unrelated cleanup was bundled in. +- Sensitive financial behavior was not changed silently. + +## Areas That Need Extra Care + +- Payments and callbacks +- Balance updates +- Multibot behavior +- Database queries and migrations +- Localization keys and admin/user flows + +## Questions + +For discussion or coordination: + +- Telegram: [@ilyarolf_dev](https://t.me/ilyarolf_dev) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..cda67bc0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,34 @@ +# Security Policy + +## Supported Versions + +Security issues should be reported for the latest maintained branch and current production-ready code. + +## Reporting a Vulnerability + +Please do **not** open public GitHub issues for security vulnerabilities. + +Report vulnerabilities privately: + +- Telegram: [@ilyarolf_dev](https://t.me/ilyarolf_dev) + +When possible, include: + +- affected area or feature +- steps to reproduce +- impact assessment +- logs, screenshots, or payload examples if safe to share + +## Sensitive Areas + +Please report issues responsibly if they affect: + +- payment callbacks +- invoice creation +- wallet withdrawals +- admin authorization +- webhook validation +- referral abuse +- multibot token handling + +We will review reports as quickly as possible and coordinate remediation privately where needed. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..6c8621e8 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,17 @@ +# Support + +## Documentation + +- [README.md](readme.md) +- [docs.md](docs.md) + +## Commercial and Maintainer Contact + +- Telegram: [@ilyarolf_dev](https://t.me/ilyarolf_dev) + +## Before Opening an Issue + +- Check the documentation first. +- Confirm your environment variables are configured. +- Include logs, stack traces, and reproduction steps. +- For security issues, use [SECURITY.md](SECURITY.md) instead of a public issue. diff --git a/readme.md b/readme.md index b359beb3..56b31409 100644 --- a/readme.md +++ b/readme.md @@ -116,6 +116,13 @@ More Telegram bot GIF demos, admin flow examples, and screenshots are available - [SQLAdmin panel](docs.md#sqladmin-web-admin-panel) - [Multibot mode](docs.md#multibot-experimental) +## 🌍 Community + +- [Contributing guide](CONTRIBUTING.md) +- [Code of Conduct](CODE_OF_CONDUCT.md) +- [Security policy](SECURITY.md) +- [Support](SUPPORT.md) + ## 🎯 Use Cases - Telegram shop bot for digital products @@ -142,6 +149,10 @@ More Telegram bot GIF demos, admin flow examples, and screenshots are available - USDT ERC20: `0xB49D720DE2630fA4C813d5B4c025706E25cF74fe` - USDT BEP20: `0xB49D720DE2630fA4C813d5B4c025706E25cF74fe` +## ⭐ Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=ilyarolf/AiogramShopBot&type=Date)](https://star-history.com/#ilyarolf/AiogramShopBot&Date) + ## 📄 License [MIT License](LICENSE) From 58ffa8b2d31f838cee96b20e8daf7c7bd64622e0 Mon Sep 17 00:00:00 2001 From: ilyarolf Date: Wed, 15 Apr 2026 00:05:30 +0300 Subject: [PATCH 6/6] Fix test imports in CI environment --- tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 3abb0a43..ead0d770 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,11 @@ import sys from types import ModuleType, SimpleNamespace +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) sys.path = [