first commit

This commit is contained in:
2026-04-12 21:58:52 +03:00
commit acfaa2a40c
44 changed files with 2895 additions and 0 deletions

14
bot/.env.example Normal file
View 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
View 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
View 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
View 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
View File

82
bot/database/db_models.py Normal file
View 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
View 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
View 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
View 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
View File

View File

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

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

View 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)}."
)

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

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

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

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

View File

View 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
View 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,
)

View File

View 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()

View 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()

View 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()

View File

60
bot/middlewares/album.py Normal file
View 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)

View 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
View 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
View File

@@ -0,0 +1 @@

250
bot/services/wata.py Normal file
View 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
View File

View 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()

View 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

Binary file not shown.

0
bot/utils/__init__.py Normal file
View File

View 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
View 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
View 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
View File

@@ -0,0 +1,44 @@
import re
def to_html(obj):
return str(obj).replace("<", "&lt;").replace(">", "&gt;")
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
View 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)