commit acfaa2a40c36cac470129a1594744a6975cb6100 Author: Евдокимов Дмитрий Date: Sun Apr 12 21:58:52 2026 +0300 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34543f0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +venv +__pycache__ +*.pyc +.git diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0472e8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Secrets and local environment files +.env +.env.* +!.env.example +bot/.env +bot/.env.* +!bot/.env.example + +# Python cache and virtual environments +__pycache__/ +*.py[cod] +*$py.class +.python-version +.venv/ +venv/ +env/ +ENV/ + +# Python tooling and test artifacts +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.pyre/ +.hypothesis/ +.tox/ +.nox/ +.coverage +.coverage.* +htmlcov/ + +# Build artifacts +build/ +dist/ +site/ +.eggs/ +*.egg-info/ +pip-wheel-metadata/ + +# Runtime files +*.log +*.pid +logs/ +bot/logs/ + +# Local databases +db.sqlite3 +db.sqlite3-journal + +# IDE and OS files +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/bot/.env.example b/bot/.env.example new file mode 100644 index 0000000..0d38883 --- /dev/null +++ b/bot/.env.example @@ -0,0 +1,14 @@ +TOKEN= +BASE_ADMIN= +REDIS_URL=redis://redisdb:6379/0 +POSTGRES_DB=postgres +POSTGRES_USER=ruby +POSTGRES_PASSWORD= +POSTGRES_HOST=postgredb +POSTGRES_PORT=5432 +TIMEZONE=Europe/Volgograd +LOG_LEVEL=INFO +WEBAPP_BASE_URL=http://127.0.0.1:8000 +WATA_API_TOKEN= +WATA_API_BASE_URL=https://api.wata.pro/api/h2h +WATA_LINK_TTL_HOURS=24 diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..5c2c750 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN useradd --create-home --shell /usr/sbin/nologin appuser + +COPY bot/requirements.txt /app/requirements.txt + +RUN pip install --upgrade pip && \ + pip install -r /app/requirements.txt + +COPY --chown=appuser:appuser ./bot /app + +USER appuser diff --git a/bot/aiogram_run.py b/bot/aiogram_run.py new file mode 100644 index 0000000..de609ed --- /dev/null +++ b/bot/aiogram_run.py @@ -0,0 +1,80 @@ +# Aiogram +from aiogram.types.bot_command_scope_all_private_chats import ( + BotCommandScopeAllPrivateChats, +) + +# Bot +from create_bot import bot, bot_description, dp, start_command, orm + +# Entry +from handlers.start import start_router, types +from handlers.admin.main import admin_main_router + +# Client handlers +from handlers.client.payments import payment_router + +# Admin handlers +from handlers.admin.list_of_users import list_of_users_router +from handlers.admin.statistic import admin_statistic_router +from handlers.admin.management import admin_management_router +from handlers.admin.mailer import admin_mailer_router +from handlers.admin.settings import admin_settings_router +from handlers.admin.blacklist import admin_blacklist_router + +# middlewares +from middlewares.users_control import * +from middlewares.album import AlbumMiddleware + +# Another +import logging +from decouple import config +from uvloop import run + + +logger = logging.getLogger(__name__) + + +async def main(): + logger.info("Bot service startup started") + + await orm.proceed_schemas() + await bot.set_my_commands(start_command, scope=BotCommandScopeAllPrivateChats()) + await bot.set_my_description(bot_description) + await orm.create_admin(int(config("BASE_ADMIN")), "base_admin", "base_admin") + + dp.message.middleware(BlacklistMiddleware()) + dp.callback_query.middleware(BlacklistMiddleware()) + dp.message.middleware(AntiFloodMiddleware()) + dp.message.middleware(AlbumMiddleware()) + + # ENTRY POINTS + dp.include_routers(start_router, admin_main_router) + + # CLIENT + dp.include_router(payment_router) + + # ADMIN + dp.include_routers( + list_of_users_router, + admin_statistic_router, + admin_management_router, + admin_mailer_router, + admin_settings_router, + admin_blacklist_router, + ) + + allowed_updates = dp.resolve_used_update_types() + logger.info( + "Bot service startup completed", + extra={"allowed_updates": allowed_updates}, + ) + + try: + await dp.start_polling(bot, allowed_updates=allowed_updates) + finally: + logger.info("Bot service shutdown") + await bot.session.close() + + +if __name__ == "__main__": + run(main()) diff --git a/bot/create_bot.py b/bot/create_bot.py new file mode 100644 index 0000000..d214b9a --- /dev/null +++ b/bot/create_bot.py @@ -0,0 +1,37 @@ +# aiogram +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder, StorageKey +from aiogram.types import BotCommand + +# cfg +from decouple import config + +# db +from database.orm import ORM + +# another +import logging, pytz + +# logging +from utils.logging_config import setup_logging + + +setup_logging(service_name="tgbot") +logger = logging.getLogger(__name__) +redis_url = config("REDIS_URL") +bot = Bot( + token=config("TOKEN"), default=DefaultBotProperties(parse_mode=ParseMode.HTML) +) +storage = RedisStorage.from_url(redis_url) +storage.key_builder = DefaultKeyBuilder(with_bot_id=True) +dp = Dispatcher(storage=storage) + +start_command = [BotCommand(command="/start", description="🔄 Перезапустить бота")] +bot_description = ( + "Вас приветствует сервис пополнения цифровых кошельков Wechat и Alipay!\n\n" + "Нажмите старт для продолжения." +) +tz = pytz.timezone(config("TIMEZONE")) +orm = ORM() diff --git a/bot/database/__init__.py b/bot/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/database/db_models.py b/bot/database/db_models.py new file mode 100644 index 0000000..1254893 --- /dev/null +++ b/bot/database/db_models.py @@ -0,0 +1,82 @@ +# sqlalchemy +from sqlalchemy.orm import declarative_base +from sqlalchemy import ( + Column, + Integer, + String, + BIGINT, + VARCHAR, + Boolean, + DateTime, + SmallInteger, + ARRAY, + DOUBLE_PRECISION, + Enum, + Numeric, + Text, +) +from sqlalchemy.dialects.postgresql import JSONB + +# types +from database.db_types import * + + +# init baseModel +BaseModel = declarative_base() + + +class User(BaseModel): + + __tablename__ = "users" + + user_id = Column(BIGINT, primary_key=True) + username = Column(VARCHAR(33), nullable=True) + fullname = Column(VARCHAR(128), nullable=False) + register_date = Column(DateTime(timezone=True), nullable=False) + + +class Admin(BaseModel): + + __tablename__ = "admins" + + user_id = Column(BIGINT, primary_key=True) + username = Column(VARCHAR(33), nullable=True) + fullname = Column(VARCHAR(128), nullable=False) + + +class Blacklist(BaseModel): + + __tablename__ = "blacklist" + + user_id = Column(BIGINT, primary_key=True) + + +class Setting(BaseModel): + + __tablename__ = "settings" + + name = Column(String, primary_key=True) + value = Column(JSONB, nullable=True) + + +class Payment(BaseModel): + + __tablename__ = "payments" + + id = Column(Integer, primary_key=True, autoincrement=True) + order_id = Column(VARCHAR(64), unique=True, nullable=False, index=True) + user_id = Column(BIGINT, nullable=False, index=True) + amount = Column(Numeric(12, 2), nullable=False) + currency = Column(VARCHAR(3), nullable=False, default="RUB") + description = Column(VARCHAR(255), nullable=True) + status = Column(VARCHAR(32), nullable=False, default="created") + payment_link_id = Column(VARCHAR(36), nullable=True) + payment_url = Column(Text, nullable=True) + payment_link_status = Column(VARCHAR(32), nullable=True) + transaction_id = Column(VARCHAR(36), nullable=True) + transaction_status = Column(VARCHAR(32), nullable=True) + error_code = Column(VARCHAR(64), nullable=True) + error_description = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), nullable=False) + updated_at = Column(DateTime(timezone=True), nullable=False) + paid_at = Column(DateTime(timezone=True), nullable=True) diff --git a/bot/database/db_types.py b/bot/database/db_types.py new file mode 100644 index 0000000..243bee4 --- /dev/null +++ b/bot/database/db_types.py @@ -0,0 +1,16 @@ +import enum + + +# class Type(enum.Enum): +# FIELD1 = "field1" +# FIELD2 = "field2" + +# @classmethod +# def from_string(cls, value: str): +# for item in cls: +# if item.value == value: +# return item +# raise ValueError(f"{value} is not a valid Type") + +# def __str__(self): +# return self.value diff --git a/bot/database/engine.py b/bot/database/engine.py new file mode 100644 index 0000000..6f59f86 --- /dev/null +++ b/bot/database/engine.py @@ -0,0 +1,18 @@ +# sqlalchemy imports +from sqlalchemy.engine import URL +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.ext.asyncio import create_async_engine as _create_async_engine +from sqlalchemy.orm import sessionmaker + +# another +from typing import Union + + +def create_async_engine(url: Union[URL, str]) -> AsyncEngine: + + return _create_async_engine(url=url, pool_pre_ping=True, pool_recycle=3600) + + +def get_session_maker(engine: AsyncEngine) -> AsyncSession: + + return sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) diff --git a/bot/database/orm.py b/bot/database/orm.py new file mode 100644 index 0000000..c41d8fb --- /dev/null +++ b/bot/database/orm.py @@ -0,0 +1,323 @@ +# sqlalchemy import +from sqlalchemy import update, select, delete, func + +# Database engine +from database.engine import create_async_engine, get_session_maker + +# DB Models +from database.db_models import * + +# Config +from decouple import config + +# Another +from datetime import datetime +from decimal import Decimal +from typing import Any + + +class ORM: + + def __init__(self): + self.async_engine = create_async_engine( + url=f"postgresql+asyncpg://{config('POSTGRES_USER')}:{config('POSTGRES_PASSWORD')}@{config('POSTGRES_HOST')}:{config('POSTGRES_PORT')}/{config('POSTGRES_DB')}" + ) + self.session_maker = get_session_maker(self.async_engine) + + async def proceed_schemas(self) -> None: + async with self.async_engine.begin() as conn: + await conn.run_sync(BaseModel.metadata.create_all) + + # *############################ + # *# USERS # + # *############################ + + async def is_user_exists(self, user_id: int) -> bool: + async with self.session_maker() as session: + async with session.begin(): + query = await session.execute( + select(User.user_id).where(User.user_id == user_id) + ) + + return query.one_or_none() is not None + + async def create_user( + self, user_id: int, username: str, fullname: str, register_date: datetime + ) -> int: + async with self.session_maker() as session: + async with session.begin(): + if not await self.is_user_exists(user_id): + user = User( + user_id=user_id, + username=username, + fullname=fullname, + register_date=register_date, + ) + + session.add(user) + await session.flush() + return user.user_id + else: + return + + async def set_users_field( + self, user_id: int, field: str, value: int | str | bool + ) -> None: + async with self.session_maker() as session: + async with session.begin(): + await session.execute( + update(User) + .where(User.user_id == user_id) + .values({getattr(User, field): value}) + ) + + async def get_user(self, user_id: int) -> User: + async with self.session_maker() as session: + async with session.begin(): + query = await session.scalars( + select(User).where(User.user_id == user_id) + ) + + return query.one_or_none() + + async def get_all_users(self) -> list[User]: + async with self.session_maker() as session: + async with session.begin(): + query = await session.scalars(select(User)) + + return query.all() + + async def get_users_count(self) -> int: + async with self.session_maker() as session: + async with session.begin(): + query = await session.scalars(select(func.count()).select_from(User)) + + return query.one_or_none() + + async def get_all_user_ids(self) -> list[int]: + async with self.session_maker() as session: + async with session.begin(): + query = await session.scalars(select(User.user_id)) + + return query.all() + + # *############################ + # *# ADMINS # + # *############################ + + async def is_admin_exists(self, user_id: int) -> bool: + async with self.session_maker() as session: + async with session.begin(): + query = await session.execute( + select(Admin.user_id).where(Admin.user_id == user_id) + ) + + return query.one_or_none() is not None + + async def create_admin(self, user_id: int, username: str, fullname: str) -> None: + async with self.session_maker() as session: + async with session.begin(): + admin = Admin(user_id=user_id, username=username, fullname=fullname) + + await session.merge(admin) + + async def get_admin(self, user_id: int) -> Admin: + async with self.session_maker() as session: + async with session.begin(): + query = await session.scalars( + select(Admin).where(Admin.user_id == user_id) + ) + + return query.one_or_none() + + async def get_all_admins(self) -> list[Admin]: + async with self.session_maker() as session: + async with session.begin(): + query = await session.scalars(select(Admin)) + + return query.all() + + async def delete_admin(self, user_id: int) -> None: + async with self.session_maker() as session: + async with session.begin(): + await session.execute(delete(Admin).where(Admin.user_id == user_id)) + + async def set_admin_field( + self, user_id: int, field: str, value: int | str | bool + ) -> None: + async with self.session_maker() as session: + async with session.begin(): + await session.execute( + update(Admin) + .where(Admin.user_id == user_id) + .values({getattr(Admin, field): value}) + ) + + # *############################ + # *# SETTINGS # + # *############################ + + async def is_setting_exists(self, name: str) -> bool: + async with self.session_maker() as session: + async with session.begin(): + query = await session.execute( + select(Setting).where(Setting.name == name) + ) + + return query.one_or_none() is not None + + async def create_setting(self, name: str, value: Any) -> None: + async with self.session_maker() as session: + async with session.begin(): + setting = Setting(name=name, value=value) + + await session.merge(setting) + + async def init_settings(self) -> None: ... + + async def get_setting_value(self, name: str) -> Any: + async with self.session_maker() as session: + async with session.begin(): + query = await session.scalars( + select(Setting.value).where(Setting.name == name) + ) + + return query.one_or_none() + + async def update_setting_value(self, name: str, value: dict | list) -> None: + async with self.session_maker() as session: + async with session.begin(): + await session.execute( + update(Setting) + .where(Setting.name == name) + .values({getattr(Setting, "value"): value}) + ) + + # *############################ + # *# BLACKLIST # + # *############################ + + async def is_blacklisted(self, user_id: int) -> bool: + async with self.session_maker() as session: + async with session.begin(): + query = await session.execute( + select(Blacklist).where(Blacklist.user_id == user_id) + ) + + return query.one_or_none() is not None + + async def create_blacklist(self, user_id: int) -> None: + async with self.session_maker() as session: + async with session.begin(): + blacklist = Blacklist(user_id=user_id) + + await session.merge(blacklist) + + async def get_all_blacklist(self) -> list[int]: + async with self.session_maker() as session: + async with session.begin(): + query = await session.scalars( + select(Blacklist.user_id).order_by(Blacklist.user_id) + ) + + return query.all() + + async def delete_blacklist(self, user_id: int) -> None: + async with self.session_maker() as session: + async with session.begin(): + await session.execute( + delete(Blacklist).where(Blacklist.user_id == user_id) + ) + + # *############################ + # *# PAYMENTS # + # *############################ + + async def create_payment( + self, + user_id: int, + order_id: str, + amount: Decimal, + currency: str, + description: str, + created_at: datetime, + ) -> int: + async with self.session_maker() as session: + async with session.begin(): + payment = Payment( + user_id=user_id, + order_id=order_id, + amount=amount, + currency=currency, + description=description, + status="created", + created_at=created_at, + updated_at=created_at, + ) + + session.add(payment) + await session.flush() + + return payment.id + + async def get_payment_by_order_id(self, order_id: str) -> Payment: + async with self.session_maker() as session: + async with session.begin(): + query = await session.scalars( + select(Payment).where(Payment.order_id == order_id) + ) + + return query.one_or_none() + + async def update_payment_link( + self, + order_id: str, + payment_link_id: str, + payment_url: str, + payment_link_status: str, + updated_at: datetime, + ) -> None: + async with self.session_maker() as session: + async with session.begin(): + await session.execute( + update(Payment) + .where(Payment.order_id == order_id) + .values( + payment_link_id=payment_link_id, + payment_url=payment_url, + payment_link_status=payment_link_status, + status="link_created", + updated_at=updated_at, + ) + ) + + async def update_payment_status( + self, + order_id: str, + status: str, + transaction_status: str | None, + updated_at: datetime, + transaction_id: str | None = None, + error_code: str | None = None, + error_description: str | None = None, + paid_at: datetime | None = None, + ) -> None: + async with self.session_maker() as session: + async with session.begin(): + values = { + "status": status, + "transaction_status": transaction_status, + "updated_at": updated_at, + "error_code": error_code, + "error_description": error_description, + } + + if transaction_id: + values["transaction_id"] = transaction_id + + if paid_at: + values["paid_at"] = paid_at + + await session.execute( + update(Payment).where(Payment.order_id == order_id).values(values) + ) diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/admin/__init__.py b/bot/handlers/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/admin/blacklist.py b/bot/handlers/admin/blacklist.py new file mode 100644 index 0000000..4e2da0e --- /dev/null +++ b/bot/handlers/admin/blacklist.py @@ -0,0 +1,221 @@ +# Aiogram +import aiogram.types as types +from aiogram.fsm.context import FSMContext +from aiogram.filters import StateFilter +from aiogram import Router, F +from aiogram.exceptions import TelegramBadRequest + +# Const +from create_bot import orm + +# Keyboards +from keyboards.admin.main_kbs import * + +# States +from states.admin_states import AdminStates, AdminBlacklistStates + +# Another +from contextlib import suppress + + +# Init +admin_blacklist_router = Router() + + +@admin_blacklist_router.message( + F.text == "🚫 Черный список", StateFilter(AdminStates.main) +) +@admin_blacklist_router.message(F.text == "↩️ Назад", StateFilter(AdminBlacklistStates)) +async def cmd_blacklist(message: types.Message, state: FSMContext): + + msg_text = "🚫 Выберите действие:" + + await message.answer(text=msg_text, reply_markup=get_blacklist_kb()) + + await state.set_state(AdminBlacklistStates.main) + + +# *############################ +# *# ADD # +# *############################ + + +@admin_blacklist_router.message( + F.text == "➕ Добавить", StateFilter(AdminBlacklistStates.main) +) +async def cmd_blacklist_add(message: types.Message, state: FSMContext): + + msg_text = f"➕ Введите User ID:" + + await message.answer(text=msg_text, reply_markup=get_back_kb()) + + await state.set_state(AdminBlacklistStates.add_blacklist) + + +@admin_blacklist_router.message(F.text, StateFilter(AdminBlacklistStates.add_blacklist)) +async def cmd_blacklist_add_finish(message: types.Message, state: FSMContext): + + # validation + if not message.text.isdigit(): + await message.answer( + text="⛔️ Только цифры! Повторите попытку:", reply_markup=get_back_kb() + ) + return + + user_id = int(message.text) + + if not await orm.is_user_exists(user_id): + await message.answer( + text="⛔️ Пользователь не существует в БД! Повторите попытку:", + reply_markup=get_back_kb(), + ) + return + + await orm.create_blacklist(user_id=user_id) + + await message.answer(text=f"✅ Черный список обновлен!") + await cmd_blacklist(message, state) + + +# *############################ +# *# DEL # +# *############################ + + +@admin_blacklist_router.message( + F.text == "➖ Удалить", StateFilter(AdminBlacklistStates.main) +) +async def cmd_blacklist_delete(message: types.Message, state: FSMContext): + + msg_text = "➖ Введите User ID:" + + await message.answer(text=msg_text, reply_markup=get_back_kb()) + + await state.set_state(AdminBlacklistStates.del_blacklist) + + +@admin_blacklist_router.message(F.text, StateFilter(AdminBlacklistStates.del_blacklist)) +async def cmd_blacklist_delete_finish(message: types.Message, state: FSMContext): + + # validation + if not message.text.isdigit(): + await message.answer( + text="⛔️ Только цифры! Повторите попытку:", reply_markup=get_back_kb() + ) + return + + user_id = int(message.text) + + if not await orm.is_blacklisted(user_id): + await message.answer( + text="⛔️ Пользователь не найден в ЧС! Повторите попытку:", + reply_markup=get_back_kb(), + ) + return + + await orm.delete_blacklist(user_id=user_id) + + await message.answer(text=f"✅ Черный список обновлен!") + + await cmd_blacklist(message, state) + + +# *############################ +# *# LIST # +# *############################ + + +@admin_blacklist_router.message( + F.text == "👁 Открыть список", StateFilter(AdminBlacklistStates.main) +) +async def cmd_blacklist_list(message: types.Message, state: FSMContext): + + await state.update_data(blacklist_offset=0) + items = await orm.get_all_blacklist() + + if not items: + await message.answer(text="💭 Список пуст.") + return + + offset = 0 + max_offset = len(items) // 10 + (1 if len(items) % 10 != 0 else 0) + + msg_text = f"🚫 Черный список {offset + 1}/{max_offset}\n\n" + + for item in items[offset * 10 : (offset + 1) * 10]: + msg_text += f"✦ {item}\n" + + await message.answer( + text=msg_text, + reply_markup=get_bookList_ikb( + prefix="admin_blacklist", + offset=0, + max_offset=max_offset, + items=[], + element_col=10, + ), + ) + + +async def cmd_blacklist_list_query(query: types.CallbackQuery, state: FSMContext): + + data = await state.get_data() + offset = data.get("blacklist_offset") + items = await orm.get_all_blacklist() + + if not items: + await query.answer(text="💭 Список пуст.") + return + + max_offset = len(items) // 10 + (1 if len(items) % 10 != 0 else 0) + + if offset < 0: + offset = max_offset - 1 + await state.update_data(blacklist_offset=offset) + elif offset >= max_offset: + offset = 0 + await state.update_data(blacklist_offset=offset) + + msg_text = f"🚫 Черный список {offset + 1}/{max_offset}\n\n" + + for item in items[offset * 10 : (offset + 1) * 10]: + msg_text += f"✦ {item}\n" + + with suppress(TelegramBadRequest): + await query.message.edit_text( + text=msg_text, + reply_markup=get_bookList_ikb( + prefix="admin_blacklist", + offset=offset, + max_offset=max_offset, + items=[], + element_col=10, + ), + ) + + await query.answer() + + +@admin_blacklist_router.callback_query( + F.data == "admin_blacklist_next", StateFilter(AdminBlacklistStates.main) +) +@admin_blacklist_router.callback_query( + F.data == "admin_blacklist_prev", StateFilter(AdminBlacklistStates.main) +) +@admin_blacklist_router.callback_query( + F.data == "admin_blacklist_status", StateFilter(AdminBlacklistStates.main) +) +async def cmd_blacklist_list_actions(query: types.CallbackQuery, state: FSMContext): + + state_data = await state.get_data() + + if query.data.endswith("next"): + await state.update_data( + blacklist_offset=state_data.get("blacklist_offset", 0) + 1 + ) + elif query.data.endswith("prev"): + await state.update_data( + blacklist_offset=state_data.get("blacklist_offset", 0) - 1 + ) + + await cmd_blacklist_list_query(query, state) diff --git a/bot/handlers/admin/list_of_users.py b/bot/handlers/admin/list_of_users.py new file mode 100644 index 0000000..0c16ed5 --- /dev/null +++ b/bot/handlers/admin/list_of_users.py @@ -0,0 +1,53 @@ +# Aiogram +import aiogram.types as types +from aiogram.fsm.context import FSMContext +from aiogram.filters import StateFilter +from aiogram import Router, F + +# Const +from create_bot import tz, orm + +# States +from states.admin_states import AdminStates + +# Another +import shutil, os +from openpyxl import load_workbook + + +# Init +list_of_users_router = Router() + + +@list_of_users_router.message( + F.text == "📑 Список пользователей", StateFilter(AdminStates.main) +) +async def cmd_list_of_users(message: types.Message, state: FSMContext): + + # copy the table + table_path = shutil.copy( + src="templates/users.xlsx", dst=f"templates/users_list.xlsx" + ) + + # load table + book = load_workbook(filename=table_path) + sheet = book["users"] + + all_clients = await orm.get_all_users() + + for row, user in enumerate(all_clients, 2): + sheet.cell(row=row, column=1, value=user.user_id) + sheet.cell(row=row, column=2, value=user.username) + sheet.cell(row=row, column=3, value=user.fullname) + sheet.cell( + row=row, + column=4, + value=user.register_date.astimezone(tz).strftime(r"%d-%m-%y %H:%M %Z"), + ) + + book.save(table_path) + + await message.answer_document(document=types.FSInputFile(table_path)) + + if os.path.exists(table_path): + os.remove(table_path) diff --git a/bot/handlers/admin/mailer.py b/bot/handlers/admin/mailer.py new file mode 100644 index 0000000..5fd6f4b --- /dev/null +++ b/bot/handlers/admin/mailer.py @@ -0,0 +1,129 @@ +# Aiogram imports +import aiogram.types as types +from aiogram.fsm.context import FSMContext +from aiogram.filters import StateFilter +from aiogram import Router, F + +# Const +from create_bot import bot, orm + +# Keyboards +from keyboards.admin.mailer_kbs import * + +# Utils +from utils.text_tools import parse_links_to_inline_markup + +# States +from states.admin_states import AdminStates, AdminMailerStates + +# Funcs +from handlers.admin.main import show_admin_menu + + +admin_mailer_router = Router() + + +@admin_mailer_router.message(F.text == "✉️ Рассылка", StateFilter(AdminStates.main)) +@admin_mailer_router.message(F.text == "↩️ Назад", StateFilter(AdminMailerStates)) +async def process_mailer_post(message: types.Message, state: FSMContext): + + msg_text = "✉️ Отправьте пост одним сообщением:" + + await message.answer(text=msg_text, reply_markup=get_back_to_main_kb()) + + await state.set_state(AdminMailerStates.post) + + +@admin_mailer_router.message(StateFilter(AdminMailerStates.post)) +async def process_mailer_ikb(message: types.Message, state: FSMContext): + + await state.update_data(admin_mailer_post=message.message_id) + + msg_text = """✉️ Введите кнопки: + +
Отправьте ссылку(и) в формате: +[Текст кнопки + ссылка] +Пример: +[Переводчик + https://t.me/TransioBot] + +Чтобы добавить несколько кнопок в один ряд, пишите ссылки рядом с предыдущими. +Формат: +[Первый текст + первая ссылка][Второй текст + вторая ссылка] + +Чтобы добавить несколько кнопок в строчку, пишите новые ссылки с новой строки. +Формат: +[Первый текст + первая ссылка] +[Второй текст + вторая ссылка]
""" + + await message.answer( + text=msg_text, reply_markup=get_skip_kb(), disable_web_page_preview=True + ) + + await state.set_state(AdminMailerStates.ikb) + + +@admin_mailer_router.message(F.text, StateFilter(AdminMailerStates.ikb)) +async def process_mailer_preview(message: types.Message, state: FSMContext): + + ikb = ( + parse_links_to_inline_markup(message.text) + if message.text != "↪️ Пропустить" + else None + ) + await state.update_data(admin_mailer_ikb=ikb) + + state_data = await state.get_data() + post = state_data.get("admin_mailer_post") + + await message.answer(text="✉️ Предпросмотр:", reply_markup=get_mailer_finish_kb()) + + try: + await bot.copy_message( + chat_id=message.from_user.id, + from_chat_id=message.from_user.id, + message_id=post, + reply_markup=get_mailer_btn_ikb(buttons_preset=ikb), + ) + except: + await message.answer(text="🔴 Ошибка!") + await process_mailer_post(message, state) + return + + await state.set_state(AdminMailerStates.preview) + + +@admin_mailer_router.message( + F.text == "🟢 Начать рассылку", StateFilter(AdminMailerStates.preview) +) +async def process_mailer_finish(message: types.Message, state: FSMContext): + + state_data = await state.get_data() + ikb = state_data.get("admin_mailer_ikb") + post = state_data.get("admin_mailer_post") + + all_users = await orm.get_all_user_ids() + + # info + await message.answer(text="▶️✉️ Рассылка запущена...") + + await state.clear() + + # back to main menu + await show_admin_menu(message, state) + + counter = 0 + for user_id in all_users: + try: + await bot.copy_message( + chat_id=user_id, + from_chat_id=message.from_user.id, + message_id=post, + reply_markup=get_mailer_btn_ikb(buttons_preset=ikb), + ) + counter += 1 + except: + pass + + await message.answer( + text=f"✅ Рассылка завершена! Сообщение отправлено {counter}/{len(all_users)}." + ) diff --git a/bot/handlers/admin/main.py b/bot/handlers/admin/main.py new file mode 100644 index 0000000..e626cc4 --- /dev/null +++ b/bot/handlers/admin/main.py @@ -0,0 +1,67 @@ +# Aiogram +import aiogram.types as types +from aiogram.fsm.context import FSMContext +from aiogram.filters import Command, StateFilter +from aiogram import Router, F + +# Const +from create_bot import orm + +# Keyboards +from keyboards.admin.main_kbs import * + +# States +from states.admin_states import ( + AdminStates, + AdminMailerStates, + AdminManagementStates, + AdminSettingsStates, + AdminBlacklistStates, +) + +# Funcs +from handlers.start import cmd_start + + +# Init +admin_main_router = Router() + + +@admin_main_router.message(Command("admin"), StateFilter("*")) +async def cmd_login_as_admin(message: types.Message, state: FSMContext): + + if message.chat.type != "private": + return + + is_admin_exists = await orm.is_admin_exists(user_id=message.from_user.id) + + if is_admin_exists: + await show_admin_menu(message, state) + else: + await message.answer(text="🤨") + + +@admin_main_router.message(F.text == "🔚 Выйти", StateFilter(AdminStates.main)) +async def cmd_admin_exit(message: types.Message, state: FSMContext): + + await message.answer(text="🚪⠀", reply_markup=types.ReplyKeyboardRemove()) + + await cmd_start(message, state) + + +@admin_main_router.message( + F.text == "↩️ Вернуться в меню", + StateFilter( + AdminManagementStates.main, + AdminMailerStates.post, + AdminSettingsStates.main, + AdminBlacklistStates.main, + ), +) +async def show_admin_menu(message: types.Message, state: FSMContext): + + msg_text = "👮‍♂️ Вы находитесь в админ-панели" + + await message.answer(text=msg_text, reply_markup=get_main_menu_kb()) + + await state.set_state(AdminStates.main) diff --git a/bot/handlers/admin/management.py b/bot/handlers/admin/management.py new file mode 100644 index 0000000..992f7fc --- /dev/null +++ b/bot/handlers/admin/management.py @@ -0,0 +1,142 @@ +# Aiogram imports +import aiogram.types as types +from aiogram.fsm.context import FSMContext +from aiogram.filters import StateFilter +from aiogram import Router, F +from aiogram.exceptions import TelegramBadRequest + +# Const +from create_bot import bot, storage, StorageKey, orm + +# Keyboards +from keyboards.admin.main_kbs import * + +# States +from states.admin_states import AdminStates, AdminManagementStates + +# Config +from decouple import config + +# Another +from contextlib import suppress + + +# Init +admin_management_router = Router() + + +@admin_management_router.message( + F.text == "👮‍♂️ Управление админами", StateFilter(AdminStates.main) +) +@admin_management_router.message( + F.text == "↩️ Назад", StateFilter(AdminManagementStates) +) +async def cmd_management(message: types.Message, state: FSMContext): + + admins = await orm.get_all_admins() + + msg_text = "👮‍♂️ Действующие администраторы\n" + + for admin in admins: + msg_text += f"✦ [{admin.user_id}]: {admin.username if admin.username else admin.fullname}\n" + + msg_text += f"\n🔽 Выберите действие:" + + await message.answer(text=msg_text, reply_markup=get_add_admins_kb()) + + await state.set_state(AdminManagementStates.main) + + +# *############################ +# *# ADD # +# *############################ + + +@admin_management_router.message( + F.text == "➕ Добавить", StateFilter(AdminManagementStates.main) +) +async def cmd_management_add_id(message: types.Message, state: FSMContext): + + msg_text = "➕ Введите User ID нового админа:" + + await message.answer(text=msg_text, reply_markup=get_back_kb()) + + await state.set_state(AdminManagementStates.add_admin) + + +@admin_management_router.message(F.text, StateFilter(AdminManagementStates.add_admin)) +async def cmd_management_add_finish(message: types.Message, state: FSMContext): + + # validation + if not message.text.isdigit(): + await message.answer( + text="⛔️ Только цифры! Повторите попытку:", reply_markup=get_back_kb() + ) + return + + user_id = int(message.text) + + if not await orm.is_user_exists(user_id): + await message.answer( + text="⛔️ Пользователь не существует в БД! Повторите попытку:", + reply_markup=get_back_kb(), + ) + return + + user = await orm.get_user(user_id) + await orm.create_admin(user.user_id, user.username, user.fullname) + await message.answer("✅ Успешно!") + await cmd_management(message, state) + + +# *############################ +# *# DELETE # +# *############################ + + +@admin_management_router.message( + F.text == "➖ Удалить", StateFilter(AdminManagementStates.main) +) +async def cmd_management_delete(message: types.Message, state: FSMContext): + + msg_text = "➖ Введите ID админа для удаления:" + + await message.answer(text=msg_text, reply_markup=get_back_kb()) + + await state.set_state(AdminManagementStates.del_admin) + + +@admin_management_router.message(F.text, StateFilter(AdminManagementStates.del_admin)) +async def cmd_management_delete_finish(message: types.Message, state: FSMContext): + + # validation + if not message.text.isdigit(): + await message.answer(text="⛔️ Только цифры! Повторите попытку:") + return + + user_id = int(message.text) + + if user_id == int(config("BASE_ADMIN")): + await message.answer( + text="⛔️ Отказано! Повторите попытку:", reply_markup=get_back_kb() + ) + return + + if not await orm.is_admin_exists(user_id): + await message.answer(text="⛔️ Админ не найден! Повторите попытку:") + return + + # change admin state + with suppress(TelegramBadRequest): + await bot.send_message( + chat_id=user_id, + text="☹️ Вы больше не являетесь админом!", + reply_markup=types.ReplyKeyboardRemove(), + ) + + await storage.set_state( + key=StorageKey(bot_id=bot.id, chat_id=user_id, user_id=user_id), state=None + ) + await orm.delete_admin(user_id) + await message.answer("✅ Успешно!") + await cmd_management(message, state) diff --git a/bot/handlers/admin/settings.py b/bot/handlers/admin/settings.py new file mode 100644 index 0000000..73497f6 --- /dev/null +++ b/bot/handlers/admin/settings.py @@ -0,0 +1,85 @@ +# Aiogram imports +import aiogram.types as types +from aiogram.fsm.context import FSMContext +from aiogram.filters import StateFilter +from aiogram import Router, F + +# Const +from create_bot import orm + +# Keyboards +from keyboards.admin.main_kbs import * + +# States +from states.admin_states import AdminStates, AdminSettingsStates + + +# Init +admin_settings_router = Router() + + +@admin_settings_router.message(F.text == "↩️ Назад", StateFilter(AdminSettingsStates)) +@admin_settings_router.message(F.text == "⚙️ Настройки", StateFilter(AdminStates.main)) +async def cmd_settings(message: types.Message, state: FSMContext): + + msg_text = "⚙️ Выберите, что хотите изменить:" + + await message.answer(text=msg_text, reply_markup=get_settings_kb()) + + await state.set_state(AdminSettingsStates.main) + + +# *############################ +# *# EDIT PHOTO # +# *############################ + + +@admin_settings_router.message( + F.text.in_({"🖼 ..."}), StateFilter(AdminSettingsStates.main) +) +async def cmd_edit_photo(message: types.Message, state: FSMContext): + + x = {"🖼 ...": "..."} + + setting_key = x.get(message.text) + await state.update_data(setting_key=setting_key) + photo = await orm.get_setting_value(setting_key) + + msg_text = f"""Текущее значение: +
{photo}
+ +⌨️ Отправьте фото для изменения:""" + + if photo: + await message.answer_photo( + photo=photo, caption=msg_text, reply_markup=get_back_kb() + ) + else: + await message.answer(text=msg_text, reply_markup=get_back_kb()) + + await state.set_state(AdminSettingsStates.edit_photo) + + +@admin_settings_router.message(F.photo, StateFilter(AdminSettingsStates.edit_photo)) +async def cmd_edit_photo_setup(message: types.Message, state: FSMContext): + + photo = message.photo[-1].file_id + + state_data = await state.get_data() + setting_key = state_data.get("setting_key") + + await orm.update_setting_value(setting_key, photo) + + msg_text = f"""Текущее значение: +
{photo}
+ +⌨️ Отправьте фото для изменения:""" + + if photo: + await message.answer_photo( + photo=photo, caption=msg_text, reply_markup=get_back_kb() + ) + else: + await message.answer(text=msg_text, reply_markup=get_back_kb()) + + await state.set_state(AdminSettingsStates.edit_photo) diff --git a/bot/handlers/admin/statistic.py b/bot/handlers/admin/statistic.py new file mode 100644 index 0000000..9b38b1b --- /dev/null +++ b/bot/handlers/admin/statistic.py @@ -0,0 +1,28 @@ +# Aiogram +import aiogram.types as types +from aiogram.fsm.context import FSMContext +from aiogram.filters import StateFilter +from aiogram import Router, F + +# Const +from create_bot import orm + +# States +from states.admin_states import AdminStates + +# Init +admin_statistic_router = Router() + + +@admin_statistic_router.message( + F.text == "📊 Статистика", StateFilter(AdminStates.main) +) +async def cmd_statistic(message: types.Message, state: FSMContext): + + users_count = await orm.get_users_count() + + msg_text = f"""📊 Статистика + +🔹 Кол-во пользователей в боте: {users_count:,} чел.""" + + await message.answer(text=msg_text) diff --git a/bot/handlers/client/__init__.py b/bot/handlers/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/client/payments.py b/bot/handlers/client/payments.py new file mode 100644 index 0000000..58b2f78 --- /dev/null +++ b/bot/handlers/client/payments.py @@ -0,0 +1,116 @@ +import logging +from datetime import datetime, timezone +from uuid import uuid4 + +import aiogram.types as types +from aiogram import Router +from aiogram.fsm.context import FSMContext +from aiogram.filters import StateFilter + +from create_bot import bot, orm +from keyboards.inline_keyboards import get_pay_link_kb +from services.wata import WataAPIError, WataClient, build_telegram_payment_return_url +from states.client_states import MainStates +from utils.amount_parser import format_rub_amount, parse_rub_amount + + +payment_router = Router() +logger = logging.getLogger(__name__) +wata_client = WataClient() + + +@payment_router.message(StateFilter(MainStates.waiting_amount)) +async def create_payment_link(message: types.Message, state: FSMContext): + if not message.text: + await message.answer("Отправьте сумму текстом. Например: 1000 рублей.") + return + + amount = parse_rub_amount(message.text) + if amount is None: + await message.answer( + "Не удалось распознать сумму. Напишите её в рублях, например: 1000 или 1000 рублей." + ) + return + + bot_username = (await bot.get_me()).username + order_id = f"tg-{message.from_user.id}-{uuid4().hex[:12]}" + description = f"WechatPayBot payment {order_id}" + + await orm.create_payment( + user_id=message.from_user.id, + order_id=order_id, + amount=amount, + currency="RUB", + description=description, + created_at=datetime.now(timezone.utc), + ) + + try: + payment_link = await wata_client.create_payment_link( + amount=amount, + order_id=order_id, + description=description, + success_redirect_url=build_telegram_payment_return_url( + bot_username=bot_username, + order_id=order_id, + success=True, + ), + fail_redirect_url=build_telegram_payment_return_url( + bot_username=bot_username, + order_id=order_id, + success=False, + ), + ) + except WataAPIError as exc: + logger.exception("Failed to create WATA payment link for order %s", order_id) + await orm.update_payment_status( + order_id=order_id, + status="error", + transaction_status=None, + updated_at=datetime.now(timezone.utc), + error_description=exc.message, + ) + await message.answer( + f"Не удалось создать ссылку на оплату.\nПричина: {exc.message}" + ) + return + except Exception: + logger.exception("Unexpected error while creating WATA payment link") + await orm.update_payment_status( + order_id=order_id, + status="error", + transaction_status=None, + updated_at=datetime.now(timezone.utc), + error_description="Internal error while creating payment link", + ) + await message.answer( + "Не удалось создать ссылку на оплату из-за внутренней ошибки. Попробуйте позже." + ) + return + + await orm.update_payment_link( + order_id=order_id, + payment_link_id=payment_link.id, + payment_url=payment_link.url, + payment_link_status=payment_link.status, + updated_at=datetime.now(timezone.utc), + ) + + logger.info( + "Payment link created", + extra={ + "user_id": message.from_user.id, + "order_id": order_id, + "amount": str(amount), + "payment_link_id": payment_link.id, + }, + ) + + await state.set_state(MainStates.main) + await message.answer( + text=( + f"Сумма к оплате: {format_rub_amount(amount)}\n\n" + "Нажмите кнопку ниже, чтобы перейти к безопасной оплате." + ), + reply_markup=get_pay_link_kb(payment_link.url), + ) diff --git a/bot/handlers/start.py b/bot/handlers/start.py new file mode 100644 index 0000000..1c536fc --- /dev/null +++ b/bot/handlers/start.py @@ -0,0 +1,167 @@ +# Aiogram +import aiogram.types as types +from aiogram.fsm.context import FSMContext +from aiogram.filters import CommandObject, CommandStart, StateFilter +from aiogram import Router + +# Utils +from utils.text_tools import to_html +from utils.amount_parser import format_rub_amount + +# Const +from create_bot import orm +from services.wata import WataAPIError, WataClient + +# States +from states.client_states import MainStates + +# Another +from datetime import datetime, timezone +import logging + + +# Init +start_router = Router() +logger = logging.getLogger(__name__) +wata_client = WataClient() + + +@start_router.message(CommandStart(), StateFilter("*")) +async def cmd_start( + message: types.Message, state: FSMContext, command: CommandObject | None = None +): + + if message.chat.type != "private": + return + + await register_user(message) + + command_args = command.args if command else None + if command_args and command_args.startswith("payment_failed_"): + order_id = command_args.removeprefix("payment_failed_") + await show_payment_result(message, state, order_id) + return + + if command_args and command_args.startswith("payment_"): + order_id = command_args.removeprefix("payment_") + await show_payment_result(message, state, order_id) + return + + await state.set_state(MainStates.waiting_amount) + await message.answer( + text=( + "Для продолжения работы укажите сумму оплаты в рублях, " + "и я отправлю ссылку для безопасной оплаты.\n\n" + "Отправьте сумму сообщением." + ), + reply_markup=types.ReplyKeyboardRemove(), + ) + + +async def register_user(message: types.Message) -> None: + user_id = message.from_user.id + username = ( + "@" + message.from_user.username + if message.from_user.username is not None + else None + ) + fullname = to_html(message.from_user.full_name) + + await orm.create_user( + user_id=user_id, + username=username, + fullname=fullname, + register_date=datetime.now(timezone.utc), + ) + + +async def show_payment_result( + message: types.Message, state: FSMContext, order_id: str +) -> None: + payment = await orm.get_payment_by_order_id(order_id) + if payment is None: + await state.set_state(MainStates.waiting_amount) + await message.answer( + text=( + "Платёж не найден.\n\n" + "Отправьте сумму сообщением, чтобы создать новую ссылку." + ), + reply_markup=types.ReplyKeyboardRemove(), + ) + return + + if payment.status not in {"paid", "declined"}: + await sync_payment_status(order_id) + payment = await orm.get_payment_by_order_id(order_id) + + await state.set_state(MainStates.main) + + if payment.status == "paid": + await message.answer( + text=( + f"Спасибо за оплату!\n\n" + f"Платёж на сумму {format_rub_amount(payment.amount)} подтверждён." + ), + reply_markup=types.ReplyKeyboardRemove(), + ) + return + + if payment.status == "declined": + error_text = "" + if payment.error_description: + error_text = f"\nПричина: {payment.error_description}" + + await message.answer( + text=( + "Оплата не была подтверждена платёжным сервисом." + f"{error_text}\n\n" + "Отправьте новую сумму сообщением, чтобы попробовать ещё раз." + ), + reply_markup=types.ReplyKeyboardRemove(), + ) + return + + await message.answer( + text=( + "Платёж ещё обрабатывается платёжным сервисом. " + "Если вы уже оплатили, подождите несколько секунд и снова откройте бота." + ), + reply_markup=types.ReplyKeyboardRemove(), + ) + + +async def sync_payment_status(order_id: str) -> None: + try: + transaction = await wata_client.find_transaction_by_order_id(order_id) + except WataAPIError: + logger.exception("WATA returned an API error while syncing order %s", order_id) + return + except Exception: + logger.exception("Unexpected error while syncing order %s", order_id) + return + + if transaction is None: + return + + paid_at = None + if transaction.payment_time: + paid_at = datetime.fromisoformat( + transaction.payment_time.strip().replace("Z", "+00:00") + ) + + local_status = "pending" + if transaction.status == "Paid": + local_status = "paid" + elif transaction.status == "Declined": + local_status = "declined" + + await orm.update_payment_status( + order_id=order_id, + status=local_status, + transaction_status=transaction.status, + transaction_id=transaction.id, + error_code=transaction.error_code, + error_description=transaction.error_description, + updated_at=datetime.now(timezone.utc), + paid_at=paid_at, + ) diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/keyboards/admin/mailer_kbs.py b/bot/keyboards/admin/mailer_kbs.py new file mode 100644 index 0000000..7f9bed4 --- /dev/null +++ b/bot/keyboards/admin/mailer_kbs.py @@ -0,0 +1,55 @@ +# Aiogram imports +from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder +from aiogram.types import InlineKeyboardButton, KeyboardButton + + +def get_back_to_main_kb(): + + builder = ReplyKeyboardBuilder() + + builder.row(KeyboardButton(text="↩️ Вернуться в меню")) + + return builder.as_markup(resize_keyboard=True) + + +def get_back_kb(): + + builder = ReplyKeyboardBuilder() + + builder.row(KeyboardButton(text="↩️ Назад")) + + return builder.as_markup(resize_keyboard=True) + + +def get_skip_kb(): + + builder = ReplyKeyboardBuilder() + + builder.add(KeyboardButton(text="↪️ Пропустить"), KeyboardButton(text="↩️ Назад")) + builder.adjust(1) + + return builder.as_markup(resize_keyboard=True) + + +def get_mailer_finish_kb(): + + builder = ReplyKeyboardBuilder() + + builder.add( + KeyboardButton(text="🟢 Начать рассылку"), KeyboardButton(text="↩️ Назад") + ) + builder.adjust(1) + + return builder.as_markup(resize_keyboard=True, is_persistent=True) + + +def get_mailer_btn_ikb(buttons_preset: list[str] | None): + + builder = InlineKeyboardBuilder() + + if buttons_preset: + for row in buttons_preset: + for btn_name, btn_url in row: + builder.row(InlineKeyboardButton(text=btn_name, url=btn_url)) + + return builder.as_markup() diff --git a/bot/keyboards/admin/main_kbs.py b/bot/keyboards/admin/main_kbs.py new file mode 100644 index 0000000..5d99014 --- /dev/null +++ b/bot/keyboards/admin/main_kbs.py @@ -0,0 +1,95 @@ +# Aiogram imports +from aiogram.utils.keyboard import ( + ReplyKeyboardBuilder, + KeyboardButton, + InlineKeyboardBuilder, +) +from aiogram.types import ( + ReplyKeyboardMarkup, + InlineKeyboardMarkup, + InlineKeyboardButton, +) + + +def get_main_menu_kb(): + + builder = ReplyKeyboardBuilder() + + builder.row(KeyboardButton(text="📊 Статистика"), KeyboardButton(text="✉️ Рассылка")) + + builder.row( + KeyboardButton(text="🚫 Черный список"), KeyboardButton(text="⚙️ Настройки") + ) + + builder.row( + KeyboardButton(text="📑 Список пользователей"), + KeyboardButton(text="👮‍♂️ Управление админами"), + ) + + builder.row(KeyboardButton(text="🔚 Выйти")) + + return builder.as_markup(resize_keyboard=True, is_persistent=True) + + +def get_add_admins_kb(): + + builder = ReplyKeyboardBuilder() + + builder.row(KeyboardButton(text="➕ Добавить"), KeyboardButton(text="➖ Удалить")) + + builder.row(KeyboardButton(text="↩️ Вернуться в меню")) + + return builder.as_markup(resize_keyboard=True, is_persistent=True) + + +def get_back_kb(): + + builder = ReplyKeyboardBuilder() + + builder.row(KeyboardButton(text="↩️ Назад")) + + return builder.as_markup(resize_keyboard=True) + + +def get_settings_kb() -> ReplyKeyboardMarkup: + + builder = ReplyKeyboardBuilder() + + builder.add(KeyboardButton(text="↩️ Вернуться в меню")) + builder.adjust(2) + + return builder.as_markup(resize_keyboard=True, is_persistent=True) + + +def get_blacklist_kb(): + + builder = ReplyKeyboardBuilder() + + builder.row(KeyboardButton(text="👁 Открыть список")) + + builder.row(KeyboardButton(text="➕ Добавить"), KeyboardButton(text="➖ Удалить")) + + builder.row(KeyboardButton(text="↩️ Вернуться в меню")) + + return builder.as_markup(resize_keyboard=True, is_persistent=True) + + +def get_bookList_ikb( + prefix: str, offset: int, max_offset: int, items: list[tuple], element_col: int = 10 +) -> InlineKeyboardMarkup: + + builder = InlineKeyboardBuilder() + + for item_id, item_name in items[offset * element_col : (offset + 1) * element_col]: + builder.row( + InlineKeyboardButton( + text=f"{item_name}", callback_data=f"{prefix}_pick_{item_id}" + ) + ) + + builder.row( + InlineKeyboardButton(text="⬅️", callback_data=f"{prefix}_prev"), + InlineKeyboardButton(text="➡️", callback_data=f"{prefix}_next"), + ) + + return builder.as_markup() diff --git a/bot/keyboards/inline_keyboards.py b/bot/keyboards/inline_keyboards.py new file mode 100644 index 0000000..0910cf4 --- /dev/null +++ b/bot/keyboards/inline_keyboards.py @@ -0,0 +1,12 @@ +# Aiogram imports +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + +PAY_BUTTON_TEXT = "💸 Оплатить" + + +def get_pay_link_kb(payment_url: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.add(InlineKeyboardButton(text=PAY_BUTTON_TEXT, url=payment_url)) + + return builder.as_markup() diff --git a/bot/keyboards/reply_keyboards.py b/bot/keyboards/reply_keyboards.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/middlewares/album.py b/bot/middlewares/album.py new file mode 100644 index 0000000..e316b71 --- /dev/null +++ b/bot/middlewares/album.py @@ -0,0 +1,60 @@ +import asyncio +from typing import Any, Dict, Union + +from aiogram import BaseMiddleware +from aiogram.types import Message + + +class AlbumMiddleware(BaseMiddleware): + def __init__(self, latency: Union[int, float] = 0.19): + # Initialize latency and album_data dictionary + self.latency = latency + self.album_data = {} + + # + def collect_album_messages(self, event: Message): + """ + Collect messages of the same media group. + """ + # # Check if media_group_id exists in album_data + if event.media_group_id not in self.album_data: + # # Create a new entry for the media group + self.album_data[event.media_group_id] = {"messages": []} + # + # # Append the new message to the media group + self.album_data[event.media_group_id]["messages"].append(event) + # + # # Return the total number of messages in the current media group + return len(self.album_data[event.media_group_id]["messages"]) + + # + async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any: + """ + Main middleware logic. + """ + # # If the event has no media_group_id, pass it to the handler immediately + if not event.media_group_id: + return await handler(event, data) + # + # # Collect messages of the same media group + total_before = self.collect_album_messages(event) + # + # # Wait for a specified latency period + await asyncio.sleep(self.latency) + # + # # Check the total number of messages after the latency + total_after = len(self.album_data[event.media_group_id]["messages"]) + # + # # If new messages were added during the latency, exit + if total_before != total_after: + return + # + # # Sort the album messages by message_id and add to data + album_messages = self.album_data[event.media_group_id]["messages"] + album_messages.sort(key=lambda x: x.message_id) + data["album"] = album_messages + # + # # Remove the media group from tracking to free up memory + del self.album_data[event.media_group_id] + # # Call the original event handler + return await handler(event, data) diff --git a/bot/middlewares/users_control.py b/bot/middlewares/users_control.py new file mode 100644 index 0000000..8758ce4 --- /dev/null +++ b/bot/middlewares/users_control.py @@ -0,0 +1,93 @@ +from aiogram import types +from aiogram import BaseMiddleware +from datetime import datetime, timedelta, timezone +from collections import deque +import asyncio + +# Const +from create_bot import orm + + +class AntiFloodMiddleware(BaseMiddleware): + + def __init__( + self, max_messages: int = 5, interval: float = 2, block_time: float = 60.0 + ): + """ + Инициализация AntiFloodMiddleware. + + :param max_messages: Максимальное количество сообщений. + :param interval: Временной интервал (в секундах) для проверки сообщений. + :param block_time: Время блокировки пользователя (в секундах). + """ + super(AntiFloodMiddleware, self).__init__() + self.max_messages = max_messages + self.interval = interval + self.block_time = block_time + self.user_messages = {} # user_id: deque of message timestamps + self.blocked_users = {} # user_id: unblock_time + self.lock = asyncio.Lock() # Для обеспечения потокобезопасности + + async def __call__(self, handler, event: types.Message, data): + user_id = event.from_user.id + current_time = datetime.now(timezone.utc) + + async with self.lock: + # Проверка, заблокирован ли пользователь + if user_id in self.blocked_users: + unblock_time = self.blocked_users[user_id] + if current_time < unblock_time: + # Пользователь всё ещё заблокирован + return + else: + # Блокировка истекла + del self.blocked_users[user_id] + + if isinstance(event, types.CallbackQuery): + return await handler(event, data) + + # Инициализация очереди сообщений для пользователя, если её ещё нет + if user_id not in self.user_messages: + self.user_messages[user_id] = deque() + + user_queue = self.user_messages[user_id] + user_queue.append(current_time) + + # Удаление сообщений, которые старше интервала + while ( + user_queue + and (current_time - user_queue[0]).total_seconds() > self.interval + ): + user_queue.popleft() + + # Проверка, превысил ли пользователь лимит сообщений + if len(user_queue) > self.max_messages: + # Блокировка пользователя + self.blocked_users[user_id] = current_time + timedelta( + seconds=self.block_time + ) + # Очистка очереди сообщений + del self.user_messages[user_id] + + await event.answer(text="🧊 Вы заморожены на 1 минуту за флуд!") + + # Отмена обработки сообщения и блокировка + return + + return await handler(event, data) + + +class BlacklistMiddleware(BaseMiddleware): + def __init__(self): + super().__init__() + + async def __call__(self, handler, event: types.Update, data: dict): + user_id = self.get_user_id(event) + if user_id: + if await orm.is_blacklisted(user_id): + return + + return await handler(event, data) + + def get_user_id(self, event: types.Update): + return event.from_user.id diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..e01bccd --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,39 @@ +aiofiles==24.1.0 +aiogram==3.17.0 +aiohappyeyeballs==2.4.6 +aiohttp==3.11.12 +aiosignal==1.3.2 +annotated-types==0.7.0 +asyncio==3.4.3 +asyncpg==0.30.0 +attrs==25.1.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +dotenv-cli==3.4.1 +et_xmlfile==2.0.0 +frozenlist==1.5.0 +greenlet==3.1.1 +idna==3.10 +magic-filter==1.0.12 +markdown-it-py==3.0.0 +mdurl==0.1.2 +multidict==6.1.0 +openpyxl==3.1.5 +propcache==0.2.1 +pydantic==2.10.6 +pydantic_core==2.27.2 +Pygments==2.19.1 +python-decouple==3.8 +redis==5.2.1 +requests==2.32.3 +rich==13.9.4 +simplejson==3.20.1 +SQLAlchemy==2.0.38 +typing_extensions==4.12.2 +urllib3==2.3.0 +uvloop==0.21.0 +yarl==1.18.3 +fastapi +uvicorn +pytz +cryptography==44.0.2 diff --git a/bot/services/__init__.py b/bot/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/bot/services/__init__.py @@ -0,0 +1 @@ + diff --git a/bot/services/wata.py b/bot/services/wata.py new file mode 100644 index 0000000..8fead22 --- /dev/null +++ b/bot/services/wata.py @@ -0,0 +1,250 @@ +from base64 import b64decode +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from decimal import Decimal +import json +import logging +from urllib.parse import urlencode +from uuid import uuid4 + +import aiohttp +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from decouple import config + + +logger = logging.getLogger(__name__) + + +class WataAPIError(Exception): + + def __init__(self, message: str): + super().__init__(message) + self.message = message + + +@dataclass(slots=True) +class WataPaymentLink: + id: str + url: str + status: str + + +@dataclass(slots=True) +class WataTransaction: + id: str + status: str + error_code: str | None + error_description: str | None + payment_time: str | None + + +class WataClient: + + def __init__(self): + self.api_base_url = config( + "WATA_API_BASE_URL", default="https://api.wata.pro/api/h2h" + ).rstrip("/") + self.api_token = config("WATA_API_TOKEN") + self.webapp_base_url = config( + "WEBAPP_BASE_URL", default="http://127.0.0.1:8000" + ).rstrip("/") + self.link_ttl_hours = int(config("WATA_LINK_TTL_HOURS", default=24)) + self.request_timeout = aiohttp.ClientTimeout(total=60) + self._public_key_pem: str | None = None + self.is_mock_mode = self.api_token.strip().lower() == "mock" + + async def create_payment_link( + self, + amount: Decimal, + order_id: str, + description: str, + success_redirect_url: str, + fail_redirect_url: str, + ) -> WataPaymentLink: + if self.is_mock_mode: + logger.info( + "Mock payment link created", + extra={"order_id": order_id, "amount": str(amount)}, + ) + return WataPaymentLink( + id=f"mock-{uuid4().hex[:12]}", + url=self._build_mock_payment_url( + order_id=order_id, + success_redirect_url=success_redirect_url, + fail_redirect_url=fail_redirect_url, + ), + status="Opened", + ) + + payload = { + "amount": float(amount), + "currency": "RUB", + "description": description, + "orderId": order_id, + "successRedirectUrl": success_redirect_url, + "failRedirectUrl": fail_redirect_url, + "expirationDateTime": ( + datetime.now(timezone.utc) + timedelta(hours=self.link_ttl_hours) + ).isoformat(), + } + + response_data = await self._request( + method="POST", + path="/links", + expected_status=200, + json_data=payload, + ) + + return WataPaymentLink( + id=response_data["id"], + url=response_data["url"], + status=response_data["status"], + ) + + async def find_transaction_by_order_id(self, order_id: str) -> WataTransaction | None: + if self.is_mock_mode: + return None + + response_data = await self._request( + method="GET", + path="/transactions/", + expected_status=200, + params={"orderId": order_id, "maxResultCount": 1}, + ) + + items = response_data.get("items", []) + if not items: + return None + + transaction = items[0] + + return WataTransaction( + id=transaction["id"], + status=transaction["status"], + error_code=transaction.get("errorCode"), + error_description=transaction.get("errorDescription"), + payment_time=transaction.get("paymentTime"), + ) + + async def verify_webhook_signature( + self, raw_body: bytes, signature_header: str + ) -> bool: + if self.is_mock_mode: + return True + + if not signature_header: + return False + + public_key_pem = await self._get_public_key() + public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8")) + + try: + public_key.verify( + b64decode(signature_header), + raw_body, + padding.PKCS1v15(), + hashes.SHA512(), + ) + except (InvalidSignature, ValueError): + return False + + return True + + async def _get_public_key(self) -> str: + if self._public_key_pem is not None: + return self._public_key_pem + + response_data = await self._request( + method="GET", + path="/public-key", + expected_status=200, + with_auth=False, + ) + self._public_key_pem = response_data["value"] + + return self._public_key_pem + + async def _request( + self, + method: str, + path: str, + expected_status: int, + params: dict | None = None, + json_data: dict | None = None, + with_auth: bool = True, + ) -> dict: + headers = {"Content-Type": "application/json"} + if with_auth: + headers["Authorization"] = f"Bearer {self.api_token}" + + request_url = f"{self.api_base_url}{path}" + + async with aiohttp.ClientSession(timeout=self.request_timeout) as session: + try: + async with session.request( + method=method, + url=request_url, + headers=headers, + params=params, + json=json_data, + ) as response: + response_text = await response.text() + except aiohttp.ClientError as exc: + logger.exception( + "WATA request failed because of transport error", + extra={"method": method, "path": path}, + ) + raise WataAPIError("Платёжный сервис временно недоступен.") from exc + + try: + response_data = json.loads(response_text) + except json.JSONDecodeError: + response_data = None + + if response_data is None: + response_data = {"raw_response": response_text} + + if response.status != expected_status: + logger.error( + "WATA request failed: %s %s returned %s. Response: %s", + method, + request_url, + response.status, + response_data, + ) + + error_payload = response_data.get("error") if isinstance(response_data, dict) else None + error_message = "Не удалось создать ссылку на оплату. Попробуйте позже." + + if error_payload and error_payload.get("details"): + error_message = error_payload["details"] + elif error_payload and error_payload.get("message"): + error_message = error_payload["message"] + + raise WataAPIError(error_message) + + return response_data + + def _build_mock_payment_url( + self, + order_id: str, + success_redirect_url: str, + fail_redirect_url: str, + ) -> str: + query_string = urlencode( + { + "success_redirect_url": success_redirect_url, + "fail_redirect_url": fail_redirect_url, + } + ) + + return f"{self.webapp_base_url}/mock/wata/pay/{order_id}?{query_string}" + + +def build_telegram_payment_return_url(bot_username: str, order_id: str, success: bool) -> str: + payload_prefix = "payment" if success else "payment_failed" + query_string = urlencode({"start": f"{payload_prefix}_{order_id}"}) + + return f"https://t.me/{bot_username}?{query_string}" diff --git a/bot/states/__init__.py b/bot/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/states/admin_states.py b/bot/states/admin_states.py new file mode 100644 index 0000000..23ea749 --- /dev/null +++ b/bot/states/admin_states.py @@ -0,0 +1,36 @@ +# Aiogram imports +from aiogram.fsm.state import State, StatesGroup + + +class AdminStates(StatesGroup): + + main = State() + + +class AdminMailerStates(StatesGroup): + + post = State() + ikb = State() + preview = State() + + +class AdminManagementStates(StatesGroup): + + main = State() + + add_admin = State() + del_admin = State() + + +class AdminSettingsStates(StatesGroup): + + main = State() + edit_photo = State() + + +class AdminBlacklistStates(StatesGroup): + + main = State() + + add_blacklist = State() + del_blacklist = State() diff --git a/bot/states/client_states.py b/bot/states/client_states.py new file mode 100644 index 0000000..586129a --- /dev/null +++ b/bot/states/client_states.py @@ -0,0 +1,8 @@ +# Aiogram imports +from aiogram.fsm.state import State, StatesGroup + + +class MainStates(StatesGroup): + + main = State() + waiting_amount = State() diff --git a/bot/templates/users.xlsx b/bot/templates/users.xlsx new file mode 100644 index 0000000..0c7d879 Binary files /dev/null and b/bot/templates/users.xlsx differ diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/utils/amount_parser.py b/bot/utils/amount_parser.py new file mode 100644 index 0000000..9b50132 --- /dev/null +++ b/bot/utils/amount_parser.py @@ -0,0 +1,34 @@ +from decimal import Decimal, InvalidOperation +import re + + +def parse_rub_amount(raw_value: str) -> Decimal | None: + normalized_value = raw_value.lower().strip().replace(",", ".") + normalized_value = normalized_value.replace(" ", "") + normalized_value = re.sub(r"руб(лей|ля|ль|ле|\.?)", "", normalized_value) + normalized_value = re.sub(r"[^\d.]", "", normalized_value) + + if not normalized_value or normalized_value.count(".") > 1: + return None + + try: + amount = Decimal(normalized_value) + except InvalidOperation: + return None + + if amount <= 0: + return None + + if amount.as_tuple().exponent < -2: + return None + + return amount.quantize(Decimal("0.01")) + + +def format_rub_amount(amount: Decimal) -> str: + normalized_amount = amount.quantize(Decimal("0.01")) + + if normalized_amount == normalized_amount.to_integral(): + return f"{int(normalized_amount)} ₽" + + return f"{normalized_amount:.2f} ₽".replace(".", ",") diff --git a/bot/utils/cfg_loader.py b/bot/utils/cfg_loader.py new file mode 100644 index 0000000..1e5243a --- /dev/null +++ b/bot/utils/cfg_loader.py @@ -0,0 +1,17 @@ +import simplejson as json + +# init +CFG_PATH = "cfg/config.json" + + +# load cfg and return it +def load_config(cfg_path=CFG_PATH): + + with open(cfg_path, "r", encoding="utf-8") as config_fp: + return json.load(config_fp) + + +def rewrite_config(obj, cfg_path=CFG_PATH): + + with open(cfg_path, "w", encoding="utf-8") as config_fp: + json.dump(obj, config_fp, indent=4) diff --git a/bot/utils/logging_config.py b/bot/utils/logging_config.py new file mode 100644 index 0000000..91e8f2b --- /dev/null +++ b/bot/utils/logging_config.py @@ -0,0 +1,103 @@ +import json +import logging +import os +import sys +from datetime import datetime, timezone + + +STANDARD_LOG_RECORD_FIELDS = { + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "message", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "thread", + "threadName", + "taskName", +} + + +class JsonFormatter(logging.Formatter): + + def __init__(self, service_name: str): + super().__init__() + self.service_name = service_name + + def format(self, record: logging.LogRecord) -> str: + log_payload = { + "timestamp": datetime.fromtimestamp( + record.created, tz=timezone.utc + ).isoformat(), + "level": record.levelname, + "service": self.service_name, + "logger": record.name, + "message": record.getMessage(), + "module": record.module, + "function": record.funcName, + "line": record.lineno, + } + + extra_fields = self._collect_extra_fields(record) + if extra_fields: + log_payload["extra"] = extra_fields + + if record.exc_info: + log_payload["exception"] = self.formatException(record.exc_info) + + return json.dumps(log_payload, ensure_ascii=False) + + def _collect_extra_fields(self, record: logging.LogRecord) -> dict: + extra_fields = {} + + for key, value in record.__dict__.items(): + if key in STANDARD_LOG_RECORD_FIELDS or key.startswith("_"): + continue + + if isinstance(value, (str, int, float, bool)) or value is None: + extra_fields[key] = value + else: + extra_fields[key] = repr(value) + + return extra_fields + + +def setup_logging(service_name: str) -> None: + log_level_name = os.getenv("LOG_LEVEL", "INFO").upper() + log_level = getattr(logging, log_level_name, logging.INFO) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.setLevel(log_level) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JsonFormatter(service_name=service_name)) + root_logger.addHandler(handler) + + for logger_name in ( + "uvicorn", + "uvicorn.error", + "uvicorn.access", + "aiogram", + "aiohttp", + "asyncio", + ): + logger = logging.getLogger(logger_name) + logger.handlers.clear() + logger.propagate = True + + logging.captureWarnings(True) diff --git a/bot/utils/text_tools.py b/bot/utils/text_tools.py new file mode 100644 index 0000000..729578c --- /dev/null +++ b/bot/utils/text_tools.py @@ -0,0 +1,44 @@ +import re + + +def to_html(obj): + + return str(obj).replace("<", "<").replace(">", ">") + + +def parse_links_to_inline_markup(message: str) -> list: + """ + Парсит сообщение с форматированными ссылками и возвращает список рядов кнопок. + + Формат входного сообщения: + - [Текст кнопки + Ссылка] для одной кнопки. + - [Кнопка1 + Ссылка1][Кнопка2 + Ссылка2] для нескольких кнопок в одном ряду. + - Каждая строка представляет отдельный ряд кнопок. + + Пример: + [Кнопка1 + https://example.com] + [Кнопка2 + https://example.org][Кнопка3 + https://example.net] + + :param message: Строка с отформатированными ссылками. + :return: Список рядов кнопок, где каждый ряд — это список кортежей (Текст, Ссылка). + """ + # Исправленное регулярное выражение для поиска [Текст + Ссылка] + pattern = re.compile(r"\[([^\[\]+]+)\s*\+\s*(https?://[^\[\]]+)\]") + + # Инициализируем список рядов кнопок + keyboard_rows = [] + + # Разбиваем сообщение на строки + lines = message.strip().split("\n") + + for line in lines: + # Находим все совпадения в строке + matches = pattern.findall(line) + if matches: + row = [] + for text, url in matches: + button = (text.strip(), url.strip()) + row.append(button) + keyboard_rows.append(row) + + return keyboard_rows diff --git a/bot/webhooks.py b/bot/webhooks.py new file mode 100644 index 0000000..cc0de60 --- /dev/null +++ b/bot/webhooks.py @@ -0,0 +1,278 @@ +import html +import json +import logging +from time import perf_counter +from datetime import datetime, timezone +from urllib.parse import urlencode + +from fastapi import FastAPI, Header, HTTPException, Query, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from database.orm import ORM +from services.wata import WataClient +from utils.logging_config import setup_logging + + +setup_logging(service_name="webhooks") +logger = logging.getLogger(__name__) +app = FastAPI(title="WeechatPayBot Webhooks") +orm = ORM() +wata_client = WataClient() + + +@app.get("/health") +async def healthcheck(): + return {"status": "ok"} + + +@app.on_event("startup") +async def on_startup(): + await orm.proceed_schemas() + logger.info("Webhook service startup completed") + + +@app.middleware("http") +async def log_http_requests(request: Request, call_next): + started_at = perf_counter() + + try: + response = await call_next(request) + except Exception: + duration_ms = round((perf_counter() - started_at) * 1000, 2) + logger.exception( + "HTTP request failed with unhandled exception", + extra={ + "method": request.method, + "path": request.url.path, + "client_ip": request.client.host if request.client else None, + "duration_ms": duration_ms, + }, + ) + raise + + duration_ms = round((perf_counter() - started_at) * 1000, 2) + logger.info( + "HTTP request handled", + extra={ + "method": request.method, + "path": request.url.path, + "status_code": response.status_code, + "client_ip": request.client.host if request.client else None, + "duration_ms": duration_ms, + }, + ) + + return response + + +@app.post("/webhooks/wata") +async def wata_webhook(request: Request, x_signature: str | None = Header(default=None)): + raw_body = await request.body() + + if not x_signature: + logger.warning("Webhook rejected because X-Signature header is missing") + raise HTTPException(status_code=400, detail="Missing X-Signature header") + + is_valid_signature = await wata_client.verify_webhook_signature(raw_body, x_signature) + if not is_valid_signature: + logger.warning("Webhook rejected because signature verification failed") + raise HTTPException(status_code=400, detail="Invalid signature") + + try: + payload = json.loads(raw_body) + except json.JSONDecodeError as exc: + logger.warning("Webhook rejected because payload is not valid JSON") + raise HTTPException(status_code=400, detail="Invalid JSON payload") from exc + + order_id = payload.get("orderId") + transaction_status = payload.get("transactionStatus") + + if not order_id or not transaction_status: + logger.warning( + "Webhook rejected because required fields are missing", + extra={ + "has_order_id": bool(order_id), + "has_transaction_status": bool(transaction_status), + }, + ) + raise HTTPException(status_code=400, detail="orderId and transactionStatus are required") + + paid_at = None + if payload.get("paymentTime"): + paid_at = datetime.fromisoformat( + payload["paymentTime"].strip().replace("Z", "+00:00") + ) + + local_status = "pending" + if transaction_status == "Paid": + local_status = "paid" + elif transaction_status == "Declined": + local_status = "declined" + + await orm.update_payment_status( + order_id=order_id, + status=local_status, + transaction_status=transaction_status, + transaction_id=payload.get("transactionId"), + error_code=payload.get("errorCode"), + error_description=payload.get("errorDescription"), + updated_at=datetime.now(timezone.utc), + paid_at=paid_at, + ) + + logger.info( + "WATA webhook processed", + extra={ + "order_id": order_id, + "transaction_status": transaction_status, + "transaction_id": payload.get("transactionId"), + "local_status": local_status, + }, + ) + + return {"ok": True} + + +@app.get("/mock/wata/pay/{order_id}", response_class=HTMLResponse) +async def mock_wata_payment_page( + order_id: str, + success_redirect_url: str = Query(...), + fail_redirect_url: str = Query(...), +): + if not wata_client.is_mock_mode: + raise HTTPException(status_code=404, detail="Mock mode is disabled") + + payment = await orm.get_payment_by_order_id(order_id) + if payment is None: + raise HTTPException(status_code=404, detail="Payment not found") + + paid_url = "/mock/wata/complete/{order_id}?{query}".format( + order_id=order_id, + query=urlencode( + {"status": "paid", "redirect_url": success_redirect_url}, + ), + ) + declined_url = "/mock/wata/complete/{order_id}?{query}".format( + order_id=order_id, + query=urlencode( + {"status": "declined", "redirect_url": fail_redirect_url}, + ), + ) + + amount_text = html.escape(str(payment.amount)) + order_id_text = html.escape(order_id) + + return f""" + + + + + + Mock WATA Checkout + + + +
+

Mock-оплата WATA

+

Это тестовая страница. Она нужна, чтобы проверить сценарий оплаты без реальной WATA.

+
+

Заказ: {order_id_text}

+

Сумма: {amount_text} RUB

+
+ +
+ + +""" + + +@app.get("/mock/wata/complete/{order_id}") +async def mock_wata_complete_payment( + order_id: str, + status: str = Query(...), + redirect_url: str = Query(...), +): + if not wata_client.is_mock_mode: + raise HTTPException(status_code=404, detail="Mock mode is disabled") + + payment = await orm.get_payment_by_order_id(order_id) + if payment is None: + raise HTTPException(status_code=404, detail="Payment not found") + + if status not in {"paid", "declined"}: + raise HTTPException(status_code=400, detail="Invalid mock payment status") + + now = datetime.now(timezone.utc) + transaction_status = "Paid" if status == "paid" else "Declined" + error_description = None if status == "paid" else "Mock declined payment" + + await orm.update_payment_status( + order_id=order_id, + status=status, + transaction_status=transaction_status, + transaction_id=f"mock-{order_id}", + error_description=error_description, + updated_at=now, + paid_at=now if status == "paid" else None, + ) + + logger.info( + "Mock payment completed", + extra={ + "order_id": order_id, + "transaction_status": transaction_status, + }, + ) + + return RedirectResponse(url=redirect_url, status_code=302) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..adfd56b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,117 @@ +services: + tgbot: + build: + context: . + dockerfile: bot/Dockerfile + init: true + restart: unless-stopped + env_file: + - ./bot/.env + environment: + PYTHONUNBUFFERED: "1" + REDIS_URL: redis://redisdb:6379/0 + POSTGRES_HOST: postgredb + POSTGRES_PORT: "5432" + LOG_LEVEL: INFO + depends_on: + redisdb: + condition: service_healthy + postgredb: + condition: service_healthy + command: ["python", "aiogram_run.py"] + stop_grace_period: 30s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + webapp: + build: + context: . + dockerfile: bot/Dockerfile + init: true + restart: unless-stopped + env_file: + - ./bot/.env + environment: + PYTHONUNBUFFERED: "1" + POSTGRES_HOST: postgredb + POSTGRES_PORT: "5432" + LOG_LEVEL: INFO + depends_on: + postgredb: + condition: service_healthy + ports: + - "127.0.0.1:8000:8000" + command: + [ + "uvicorn", + "webhooks:app", + "--host", + "0.0.0.0", + "--port", + "8000", + "--proxy-headers", + "--forwarded-allow-ips=*", + "--no-access-log" + ] + stop_grace_period: 30s + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5)" + ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 15s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + + redisdb: + image: redis:6-alpine + init: true + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes", "--save", "60", "1000"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + postgredb: + image: postgres:16-alpine + init: true + restart: unless-stopped + env_file: + - ./bot/.env + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 5 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + postgres_data: + redis_data: