first commit

This commit is contained in:
2026-05-12 23:37:04 +03:00
commit aff0bc2990
67 changed files with 3984 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# ========================
# Telegram
# ========================
# Токен Telegram-бота от @BotFather
TOKEN=your_telegram_bot_token
# Telegram user_id главного администратора
BASE_ADMIN=123456789
# ========================
# Redis
# ========================
# Для локального запуска можно оставить localhost.
# В Docker Compose это значение переопределяется на redis://redisdb:6379/0
REDIS_URL=redis://127.0.0.1:6379/0
# ========================
# PostgreSQL
# ========================
# Для локального запуска можно оставить localhost.
# В Docker Compose POSTGRES_HOST переопределяется на postgredb
POSTGRES_DB=gorychbot
POSTGRES_USER=postgres
POSTGRES_PASSWORD=change_me
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
# ========================
# App
# ========================
# Таймзона для времени и дат внутри бота
TIMEZONE=Europe/Moscow
# Прокси для Telegram Bot API.
# Формат:
# - socks5:ip:port
# - http:ip:port
# - socks5:ip:port:user:pass
# - http:ip:port:user:pass
BOT_PROXY=
# URL сервиса ответов.
# Для локального запуска можно оставить localhost.
# В Docker Compose это значение переопределяется на http://rag_api:8000
RAG_API_URL=http://127.0.0.1:8001
# Таймаут запроса к RAG API в секундах
RAG_API_TIMEOUT_SECONDS=60
+14
View File
@@ -0,0 +1,14 @@
# базовый образ Python
FROM python:3.13-alpine
# рабочая директория
WORKDIR /app
# файл зависимостей
COPY bot/requirements.txt /app/
# устанавливаем зависимости
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
COPY ./bot /app
+65
View File
@@ -0,0 +1,65 @@
# Aiogram
from aiogram.types.bot_command_scope_all_private_chats import (
BotCommandScopeAllPrivateChats,
)
# Bot
from create_bot import bot, 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 import client_main_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
from decouple import config
from uvloop import run
async def main():
await orm.proceed_schemas()
await bot.set_my_commands(start_command, scope=BotCommandScopeAllPrivateChats())
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_routers(client_main_router)
# ADMIN
dp.include_routers(
list_of_users_router,
admin_statistic_router,
admin_management_router,
admin_mailer_router,
admin_settings_router,
admin_blacklist_router,
)
# await bot.delete_webhook(drop_pending_updates = True)
await dp.start_polling(bot)
if __name__ == "__main__":
run(main())
+41
View File
@@ -0,0 +1,41 @@
# 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
# utils
from utils.proxy import build_bot_session
# another
import logging, pytz
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
redis_url = config("REDIS_URL")
bot_session = build_bot_session()
bot = Bot(
token=config("TOKEN"),
session=bot_session,
default=DefaultBotProperties(
parse_mode=ParseMode.HTML,
link_preview_is_disabled=True,
),
)
storage = RedisStorage.from_url(redis_url)
storage.key_builder = DefaultKeyBuilder(with_bot_id=True)
dp = Dispatcher(storage=storage)
start_command = [BotCommand(command="/start", description="🔄 Перезапустить бота")]
tz = pytz.timezone(config("TIMEZONE"))
orm = ORM()
View File
+57
View File
@@ -0,0 +1,57 @@
# sqlalchemy
from sqlalchemy.orm import declarative_base
from sqlalchemy import (
Column,
Integer,
String,
BIGINT,
VARCHAR,
Boolean,
DateTime,
SmallInteger,
ARRAY,
DOUBLE_PRECISION,
Enum,
)
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)
+16
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
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)
+229
View File
@@ -0,0 +1,229 @@
# 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 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)
)
View File
View File
+221
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)
+53
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)
+129
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)}."
)
+67
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)
+142
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)
+85
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)
+28
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)
+1
View File
@@ -0,0 +1 @@
from .main import client_main_router
+154
View File
@@ -0,0 +1,154 @@
# Aiogram
import aiogram.types as types
from aiogram import Router, F
from aiogram.filters import StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import BufferedInputFile
import aiohttp
# Keyboards
from keyboards.reply_keyboards import get_client_main_kb
# Services
from services.rag_api import ask_rag_api, RagApiError
# States
from states.client_states import MainStates
# Utils
from utils.text_tools import format_telegram_html
client_main_router = Router()
POPULAR_QUESTION_MAP = {
"🕒 До скольки вы работаете?": "До скольки вы работаете?",
"🚚 Как работает доставка?": "Есть ли доставка и как заказать?",
"🌯 Что посоветуете из шаурмы?": "Что посоветуете из шаурмы?",
"🍕 Подобрать пиццу до 400 ₽": "Подбери вкусную пиццу до 400 рублей",
"🧀 Что у вас есть с сыром?": "Что у вас есть с сыром?",
"🔥 Что у вас есть острое?": "Что у вас есть острое?",
}
MAX_HISTORY_MESSAGES = 8
MAX_MENU_CARDS = 3
PHOTO_DOWNLOAD_TIMEOUT_SECONDS = 20
def trim_history(history: list[dict[str, str]]) -> list[dict[str, str]]:
return history[-MAX_HISTORY_MESSAGES:]
def shorten_text(text: str, limit: int = 240) -> str:
cleaned = " ".join(str(text).split())
if len(cleaned) <= limit:
return cleaned
return cleaned[: limit - 1].rstrip() + ""
def build_menu_item_caption(item: dict[str, str]) -> str:
name = format_telegram_html(item.get("name", "Позиция из меню"))
raw_price = item.get("price_label") or "Цена уточняется"
if item.get("price") is None or "бесплат" in str(raw_price).lower():
raw_price = "Цена уточняется"
price = format_telegram_html(raw_price)
description = format_telegram_html(shorten_text(item.get("description", "")))
size = format_telegram_html(item.get("size") or "")
category = format_telegram_html(item.get("category") or "")
caption_parts = [f"<b>{name}</b>", f"💸 {price}"]
if category or size:
meta = "".join(part for part in [category, size] if part)
caption_parts.append(meta)
if description:
caption_parts.append(description)
return "\n".join(caption_parts)
async def send_menu_cards(message: types.Message, items: list[dict[str, str]]) -> None:
for item in items[:MAX_MENU_CARDS]:
caption = build_menu_item_caption(item)
photo_url = item.get("photo_url")
if photo_url:
try:
photo = await download_menu_photo(str(photo_url), str(item.get("item_id") or "menu"))
await message.answer_photo(photo=photo, caption=caption)
continue
except TelegramBadRequest:
pass
except Exception:
try:
await message.answer_photo(photo=photo_url, caption=caption)
continue
except TelegramBadRequest:
pass
await message.answer(caption)
async def download_menu_photo(photo_url: str, item_id: str) -> BufferedInputFile:
timeout = aiohttp.ClientTimeout(total=PHOTO_DOWNLOAD_TIMEOUT_SECONDS)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(photo_url) as response:
response.raise_for_status()
content = await response.read()
extension = photo_url.rsplit(".", 1)[-1].split("?", 1)[0] if "." in photo_url else "jpg"
filename = f"menu_{item_id}.{extension or 'jpg'}"
return BufferedInputFile(content, filename=filename)
@client_main_router.message(F.text == "🧹 Очистить диалог", StateFilter(MainStates.main))
async def clear_dialog(message: types.Message, state: FSMContext):
await state.update_data(rag_history=[])
await message.answer(
"🧼 Диалог очищен. Можете задать новый вопрос о меню, доставке или заведении.",
reply_markup=get_client_main_kb(),
)
@client_main_router.message(F.text, StateFilter(MainStates.main))
async def handle_client_message(message: types.Message, state: FSMContext):
if message.chat.type != "private":
return
if not message.text:
return
user_text = POPULAR_QUESTION_MAP.get(message.text, message.text)
state_data = await state.get_data()
history = state_data.get("rag_history", [])
waiting_message = await message.answer("🤖 Думаю над ответом...")
try:
response = await ask_rag_api(message=user_text, history=history)
except RagApiError:
await waiting_message.edit_text(
"⚠️ Не получилось обратиться к сервису ответов. Попробуйте ещё раз через минуту."
)
return
except Exception:
await waiting_message.edit_text(
"⚠️ Что-то пошло не так. Попробуйте отправить вопрос ещё раз."
)
return
answer = format_telegram_html(response.get("answer", "⚠️ Не удалось получить ответ."))
updated_history = trim_history(
[
*history,
{"role": "user", "content": user_text},
{"role": "assistant", "content": answer},
]
)
await state.update_data(rag_history=updated_history)
await waiting_message.edit_text(answer)
tool_results = response.get("tool_results") or []
if tool_results:
await send_menu_cards(message, tool_results)
+58
View File
@@ -0,0 +1,58 @@
# Aiogram
import aiogram.types as types
from aiogram.fsm.context import FSMContext
from aiogram.filters import CommandStart, StateFilter
from aiogram import Router, F
# Utils
from utils.text_tools import to_html
# Const
from create_bot import orm
# Keyboards
from keyboards.reply_keyboards import get_client_main_kb
# States
from states.client_states import MainStates
# Another
from datetime import datetime, timezone
# Init
start_router = Router()
@start_router.message(CommandStart(), StateFilter("*"))
async def cmd_start(message: types.Message, state: FSMContext):
if message.chat.type != "private":
return
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),
)
msg_text = (
f"👋 Привет, {fullname}!\n\n"
"Я бот шаурмечной <b>Горыч</b>.\n"
"Подскажу по меню, доставке, режиму работы и помогу подобрать блюдо.\n\n"
"✨ Выберите популярный вопрос ниже или просто напишите свой."
)
await message.answer(text=msg_text, reply_markup=get_client_main_kb())
await state.update_data(rag_history=[])
await state.set_state(MainStates.main)
View File
+55
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()
+95
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()
+3
View File
@@ -0,0 +1,3 @@
# Aiogram imports
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
+28
View File
@@ -0,0 +1,28 @@
# Aiogram imports
from aiogram.utils.keyboard import ReplyKeyboardBuilder
from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
POPULAR_QUESTIONS = [
"🕒 До скольки вы работаете?",
"🚚 Как работает доставка?",
"🌯 Что посоветуете из шаурмы?",
"🍕 Подобрать пиццу до 400 ₽",
"🧀 Что у вас есть с сыром?",
"🔥 Что у вас есть острое?",
]
def get_client_main_kb() -> ReplyKeyboardMarkup:
builder = ReplyKeyboardBuilder()
for question in POPULAR_QUESTIONS:
builder.add(KeyboardButton(text=question))
builder.add(KeyboardButton(text="🧹 Очистить диалог"))
builder.adjust(2, 2, 2, 1)
return builder.as_markup(
resize_keyboard=True,
input_field_placeholder="Спросите про меню, доставку или режим работы",
)
+60
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)
+93
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
View File
@@ -0,0 +1,39 @@
aiofiles==24.1.0
aiogram==3.17.0
aiohappyeyeballs==2.4.6
aiohttp==3.11.12
aiohttp-socks==0.10.1
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
+1
View File
@@ -0,0 +1 @@
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from typing import Any
import aiohttp
from decouple import config
RAG_API_URL = config("RAG_API_URL", default="http://127.0.0.1:8001")
RAG_API_TIMEOUT_SECONDS = float(config("RAG_API_TIMEOUT_SECONDS", default="60"))
class RagApiError(Exception):
pass
async def ask_rag_api(message: str, history: list[dict[str, str]]) -> dict[str, Any]:
timeout = aiohttp.ClientTimeout(total=RAG_API_TIMEOUT_SECONDS)
payload = {
"message": message,
"history": history,
}
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(f"{RAG_API_URL}/chat", json=payload) as response:
if response.status != 200:
text = await response.text()
raise RagApiError(f"RAG API returned {response.status}: {text}")
return await response.json()
View File
+36
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()
+7
View File
@@ -0,0 +1,7 @@
# Aiogram imports
from aiogram.fsm.state import State, StatesGroup
class MainStates(StatesGroup):
main = State()
Binary file not shown.
View File
+17
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)
+44
View File
@@ -0,0 +1,44 @@
from __future__ import annotations
from aiohttp import BasicAuth
from aiogram.client.session.aiohttp import AiohttpSession
from decouple import config
SUPPORTED_PROXY_PROTOCOLS = {"http", "socks5"}
def build_bot_session() -> AiohttpSession | None:
proxy_raw = config("BOT_PROXY", default="").strip()
if not proxy_raw:
return None
proxy = parse_proxy_value(proxy_raw)
return AiohttpSession(proxy=proxy)
def parse_proxy_value(proxy_raw: str) -> str | tuple[str, BasicAuth]:
if "://" in proxy_raw:
return proxy_raw
parts = proxy_raw.split(":")
if len(parts) not in {3, 5}:
raise ValueError(
"BOT_PROXY must be in format protocol:ip:port or protocol:ip:port:user:pass"
)
protocol, host, port = parts[:3]
protocol = protocol.lower()
if protocol not in SUPPORTED_PROXY_PROTOCOLS:
raise ValueError(
f"Unsupported proxy protocol '{protocol}'. Supported: http, socks5"
)
proxy_url = f"{protocol}://{host}:{port}"
if len(parts) == 3:
return proxy_url
username, password = parts[3], parts[4]
return proxy_url, BasicAuth(login=username, password=password)
+119
View File
@@ -0,0 +1,119 @@
import html
import re
from html.parser import HTMLParser
def to_html(obj):
return str(obj).replace("<", "&lt;").replace(">", "&gt;")
class TelegramHTMLSanitizer(HTMLParser):
ALLOWED_TAGS = {"b", "i", "u", "s", "code", "pre", "a"}
TAG_ALIASES = {"strong": "b", "em": "i"}
ALLOWED_HREF_PREFIXES = ("http://", "https://", "tg://", "mailto:")
def __init__(self) -> None:
super().__init__(convert_charrefs=False)
self.parts: list[str] = []
self.tag_stack: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
normalized_tag = self.TAG_ALIASES.get(tag, tag)
if normalized_tag not in self.ALLOWED_TAGS:
return
if normalized_tag == "a":
href = next((value for key, value in attrs if key == "href" and value), None)
if not href or not href.startswith(self.ALLOWED_HREF_PREFIXES):
return
safe_href = html.escape(href, quote=True)
self.parts.append(f'<a href="{safe_href}">')
self.tag_stack.append(normalized_tag)
return
self.parts.append(f"<{normalized_tag}>")
self.tag_stack.append(normalized_tag)
def handle_endtag(self, tag: str) -> None:
normalized_tag = self.TAG_ALIASES.get(tag, tag)
if normalized_tag not in self.ALLOWED_TAGS:
return
for index in range(len(self.tag_stack) - 1, -1, -1):
if self.tag_stack[index] == normalized_tag:
del self.tag_stack[index]
self.parts.append(f"</{normalized_tag}>")
break
def handle_data(self, data: str) -> None:
self.parts.append(html.escape(data, quote=False))
def handle_entityref(self, name: str) -> None:
self.parts.append(f"&{name};")
def handle_charref(self, name: str) -> None:
self.parts.append(f"&#{name};")
def get_html(self) -> str:
while self.tag_stack:
self.parts.append(f"</{self.tag_stack.pop()}>")
return "".join(self.parts)
def markdown_to_telegram_html(text: str) -> str:
prepared = text.replace("\r\n", "\n").strip()
prepared = re.sub(
r"\[([^\]]+)\]\((https?://[^\s)]+)\)",
r'<a href="\2">\1</a>',
prepared,
)
prepared = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", prepared, flags=re.DOTALL)
prepared = re.sub(r"__(.+?)__", r"<b>\1</b>", prepared, flags=re.DOTALL)
prepared = re.sub(r"(?m)^[ \t]*[*-]\s+", "", prepared)
return prepared
def format_telegram_html(text: str) -> str:
prepared = markdown_to_telegram_html(str(text))
sanitizer = TelegramHTMLSanitizer()
sanitizer.feed(prepared)
sanitizer.close()
return sanitizer.get_html()
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
+16
View File
@@ -0,0 +1,16 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello, this is the test webhook endpoint!"}
@app.post("/webhook")
async def webhook(request: Request):
data = await request.json()
return JSONResponse(content={"status": "ok", "data": data})