first commit
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.git
|
||||||
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@@ -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
|
||||||
14
bot/.env.example
Normal file
14
bot/.env.example
Normal file
@@ -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
|
||||||
18
bot/Dockerfile
Normal file
18
bot/Dockerfile
Normal file
@@ -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
|
||||||
80
bot/aiogram_run.py
Normal file
80
bot/aiogram_run.py
Normal file
@@ -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())
|
||||||
37
bot/create_bot.py
Normal file
37
bot/create_bot.py
Normal file
@@ -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()
|
||||||
0
bot/database/__init__.py
Normal file
0
bot/database/__init__.py
Normal file
82
bot/database/db_models.py
Normal file
82
bot/database/db_models.py
Normal file
@@ -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)
|
||||||
16
bot/database/db_types.py
Normal file
16
bot/database/db_types.py
Normal file
@@ -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
|
||||||
18
bot/database/engine.py
Normal file
18
bot/database/engine.py
Normal file
@@ -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)
|
||||||
323
bot/database/orm.py
Normal file
323
bot/database/orm.py
Normal file
@@ -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)
|
||||||
|
)
|
||||||
0
bot/handlers/__init__.py
Normal file
0
bot/handlers/__init__.py
Normal file
0
bot/handlers/admin/__init__.py
Normal file
0
bot/handlers/admin/__init__.py
Normal file
221
bot/handlers/admin/blacklist.py
Normal file
221
bot/handlers/admin/blacklist.py
Normal file
@@ -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"<b>🚫 Черный список {offset + 1}/{max_offset}</b>\n\n"
|
||||||
|
|
||||||
|
for item in items[offset * 10 : (offset + 1) * 10]:
|
||||||
|
msg_text += f"✦ <code>{item}</code>\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"<b>🚫 Черный список {offset + 1}/{max_offset}</b>\n\n"
|
||||||
|
|
||||||
|
for item in items[offset * 10 : (offset + 1) * 10]:
|
||||||
|
msg_text += f"✦ <code>{item}</code>\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)
|
||||||
53
bot/handlers/admin/list_of_users.py
Normal file
53
bot/handlers/admin/list_of_users.py
Normal file
@@ -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)
|
||||||
129
bot/handlers/admin/mailer.py
Normal file
129
bot/handlers/admin/mailer.py
Normal file
@@ -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 = """✉️ Введите кнопки:
|
||||||
|
|
||||||
|
<blockquote>Отправьте ссылку(и) в формате:
|
||||||
|
[Текст кнопки + ссылка]
|
||||||
|
Пример:
|
||||||
|
[Переводчик + https://t.me/TransioBot]
|
||||||
|
|
||||||
|
Чтобы добавить несколько кнопок в один ряд, пишите ссылки рядом с предыдущими.
|
||||||
|
Формат:
|
||||||
|
[Первый текст + первая ссылка][Второй текст + вторая ссылка]
|
||||||
|
|
||||||
|
Чтобы добавить несколько кнопок в строчку, пишите новые ссылки с новой строки.
|
||||||
|
Формат:
|
||||||
|
[Первый текст + первая ссылка]
|
||||||
|
[Второй текст + вторая ссылка]</blockquote>"""
|
||||||
|
|
||||||
|
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)}."
|
||||||
|
)
|
||||||
67
bot/handlers/admin/main.py
Normal file
67
bot/handlers/admin/main.py
Normal file
@@ -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)
|
||||||
142
bot/handlers/admin/management.py
Normal file
142
bot/handlers/admin/management.py
Normal file
@@ -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 = "<i>👮♂️ Действующие администраторы</i>\n"
|
||||||
|
|
||||||
|
for admin in admins:
|
||||||
|
msg_text += f"✦ [<code>{admin.user_id}</code>]: {admin.username if admin.username else admin.fullname}\n"
|
||||||
|
|
||||||
|
msg_text += f"\n<b>🔽 Выберите действие:</b>"
|
||||||
|
|
||||||
|
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)
|
||||||
85
bot/handlers/admin/settings.py
Normal file
85
bot/handlers/admin/settings.py
Normal file
@@ -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"""<b>Текущее значение:</b>
|
||||||
|
<blockquote>{photo}</blockquote>
|
||||||
|
|
||||||
|
⌨️ Отправьте фото для изменения:"""
|
||||||
|
|
||||||
|
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"""<b>Текущее значение:</b>
|
||||||
|
<blockquote>{photo}</blockquote>
|
||||||
|
|
||||||
|
⌨️ Отправьте фото для изменения:"""
|
||||||
|
|
||||||
|
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)
|
||||||
28
bot/handlers/admin/statistic.py
Normal file
28
bot/handlers/admin/statistic.py
Normal file
@@ -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"""<i>📊 Статистика</i>
|
||||||
|
|
||||||
|
🔹 Кол-во пользователей в боте: {users_count:,} чел."""
|
||||||
|
|
||||||
|
await message.answer(text=msg_text)
|
||||||
0
bot/handlers/client/__init__.py
Normal file
0
bot/handlers/client/__init__.py
Normal file
116
bot/handlers/client/payments.py
Normal file
116
bot/handlers/client/payments.py
Normal file
@@ -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),
|
||||||
|
)
|
||||||
167
bot/handlers/start.py
Normal file
167
bot/handlers/start.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
0
bot/keyboards/__init__.py
Normal file
0
bot/keyboards/__init__.py
Normal file
55
bot/keyboards/admin/mailer_kbs.py
Normal file
55
bot/keyboards/admin/mailer_kbs.py
Normal file
@@ -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()
|
||||||
95
bot/keyboards/admin/main_kbs.py
Normal file
95
bot/keyboards/admin/main_kbs.py
Normal file
@@ -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()
|
||||||
12
bot/keyboards/inline_keyboards.py
Normal file
12
bot/keyboards/inline_keyboards.py
Normal file
@@ -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()
|
||||||
0
bot/keyboards/reply_keyboards.py
Normal file
0
bot/keyboards/reply_keyboards.py
Normal file
60
bot/middlewares/album.py
Normal file
60
bot/middlewares/album.py
Normal file
@@ -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)
|
||||||
93
bot/middlewares/users_control.py
Normal file
93
bot/middlewares/users_control.py
Normal file
@@ -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
|
||||||
39
bot/requirements.txt
Normal file
39
bot/requirements.txt
Normal file
@@ -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
|
||||||
1
bot/services/__init__.py
Normal file
1
bot/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
250
bot/services/wata.py
Normal file
250
bot/services/wata.py
Normal file
@@ -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}"
|
||||||
0
bot/states/__init__.py
Normal file
0
bot/states/__init__.py
Normal file
36
bot/states/admin_states.py
Normal file
36
bot/states/admin_states.py
Normal file
@@ -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()
|
||||||
8
bot/states/client_states.py
Normal file
8
bot/states/client_states.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Aiogram imports
|
||||||
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
|
|
||||||
|
|
||||||
|
class MainStates(StatesGroup):
|
||||||
|
|
||||||
|
main = State()
|
||||||
|
waiting_amount = State()
|
||||||
BIN
bot/templates/users.xlsx
Normal file
BIN
bot/templates/users.xlsx
Normal file
Binary file not shown.
0
bot/utils/__init__.py
Normal file
0
bot/utils/__init__.py
Normal file
34
bot/utils/amount_parser.py
Normal file
34
bot/utils/amount_parser.py
Normal file
@@ -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(".", ",")
|
||||||
17
bot/utils/cfg_loader.py
Normal file
17
bot/utils/cfg_loader.py
Normal file
@@ -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)
|
||||||
103
bot/utils/logging_config.py
Normal file
103
bot/utils/logging_config.py
Normal file
@@ -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)
|
||||||
44
bot/utils/text_tools.py
Normal file
44
bot/utils/text_tools.py
Normal file
@@ -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
|
||||||
278
bot/webhooks.py
Normal file
278
bot/webhooks.py
Normal file
@@ -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"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Mock WATA Checkout</title>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: #f4f6f8;
|
||||||
|
color: #17202a;
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px 16px;
|
||||||
|
}}
|
||||||
|
.card {{
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
||||||
|
}}
|
||||||
|
h1 {{
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}}
|
||||||
|
p {{
|
||||||
|
line-height: 1.5;
|
||||||
|
}}
|
||||||
|
.meta {{
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px 0 24px;
|
||||||
|
}}
|
||||||
|
.actions {{
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}}
|
||||||
|
.button {{
|
||||||
|
display: inline-block;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
}}
|
||||||
|
.button-success {{
|
||||||
|
background: #198754;
|
||||||
|
}}
|
||||||
|
.button-declined {{
|
||||||
|
background: #dc3545;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h1>Mock-оплата WATA</h1>
|
||||||
|
<p>Это тестовая страница. Она нужна, чтобы проверить сценарий оплаты без реальной WATA.</p>
|
||||||
|
<div class="meta">
|
||||||
|
<p><strong>Заказ:</strong> {order_id_text}</p>
|
||||||
|
<p><strong>Сумма:</strong> {amount_text} RUB</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button button-success" href="{html.escape(paid_url, quote=True)}">Успешная оплата</a>
|
||||||
|
<a class="button button-declined" href="{html.escape(declined_url, quote=True)}">Отклонить оплату</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
117
docker-compose.yml
Normal file
117
docker-compose.yml
Normal file
@@ -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:
|
||||||
Reference in New Issue
Block a user