first commit
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
# Telegram bot token from @BotFather
|
||||
TOKEN=123456789:replace_me
|
||||
|
||||
# Optional proxy for Telegram Bot API.
|
||||
# Leave empty if you connect directly.
|
||||
# Format: protocol(http/socks5):ip:port:user:pass
|
||||
TELEGRAM_BOT_PROXY=
|
||||
|
||||
# Telegram user id that will have admin access in the bot
|
||||
BASE_ADMIN=000000000
|
||||
|
||||
# URL of the FastAPI service.
|
||||
# Inside docker compose use the internal service name.
|
||||
RAG_API_URL=http://api:8080
|
||||
|
||||
# Redis connection for FSM/storage.
|
||||
# Inside docker compose use the internal service name.
|
||||
REDIS_URL=redis://redisdb:6379/0
|
||||
|
||||
# Postgres connection used by the shared DB layer.
|
||||
# Inside docker compose use the internal service name.
|
||||
POSTGRES_DB=law_bot_db
|
||||
POSTGRES_USER=lawbot_user
|
||||
POSTGRES_PASSWORD=change_me
|
||||
POSTGRES_HOST=postgredb
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# All user-facing dates and times are shown in this timezone
|
||||
TIMEZONE=Europe/Moscow
|
||||
@@ -0,0 +1,16 @@
|
||||
# базовый образ Python
|
||||
FROM python:3.13-alpine
|
||||
|
||||
# рабочая директория
|
||||
WORKDIR /app/bot
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
# файл зависимостей
|
||||
COPY bot/requirements.txt /app/
|
||||
|
||||
# устанавливаем зависимости
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
COPY . /app
|
||||
@@ -0,0 +1,100 @@
|
||||
# Aiogram
|
||||
from aiogram.types.bot_command_scope_all_private_chats import (
|
||||
BotCommandScopeAllPrivateChats,
|
||||
)
|
||||
from aiogram.exceptions import TelegramNetworkError
|
||||
|
||||
# Bot
|
||||
from create_bot import bot, dp, start_command, orm
|
||||
|
||||
# Entry
|
||||
from handlers.start import start_router
|
||||
from handlers.admin.main import admin_main_router
|
||||
|
||||
# Client handlers
|
||||
from handlers.client.main import client_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 asyncio
|
||||
import logging
|
||||
from decouple import config
|
||||
from uvloop import run
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def safe_set_my_commands(retries: int = 3, delay_seconds: int = 5) -> None:
|
||||
for attempt in range(1, retries + 1):
|
||||
try:
|
||||
await bot.set_my_commands(
|
||||
start_command,
|
||||
scope=BotCommandScopeAllPrivateChats(),
|
||||
request_timeout=120,
|
||||
)
|
||||
return
|
||||
except TelegramNetworkError as exc:
|
||||
if attempt == retries:
|
||||
logger.warning(
|
||||
"Could not set bot commands after %s attempts: %s",
|
||||
retries,
|
||||
exc,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
"Telegram API timeout while setting commands, retry %s/%s in %ss",
|
||||
attempt,
|
||||
retries,
|
||||
delay_seconds,
|
||||
)
|
||||
await asyncio.sleep(delay_seconds)
|
||||
|
||||
|
||||
async def main():
|
||||
try:
|
||||
await orm.init_schema()
|
||||
await safe_set_my_commands()
|
||||
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(client_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)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
await orm.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
# aiogram
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
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 shared import ORM
|
||||
|
||||
# another
|
||||
import logging, pytz
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
TELEGRAM_API_TIMEOUT = 120.0
|
||||
|
||||
|
||||
def build_telegram_proxy_url(proxy_value: str | None) -> str | None:
|
||||
if proxy_value is None:
|
||||
return None
|
||||
|
||||
proxy_value = proxy_value.strip()
|
||||
if not proxy_value:
|
||||
return None
|
||||
|
||||
parts = proxy_value.split(":")
|
||||
if len(parts) < 3:
|
||||
raise ValueError(
|
||||
"TELEGRAM_BOT_PROXY must be in format protocol(http/socks5):ip:port:user:pass"
|
||||
)
|
||||
|
||||
protocol, host, port, *auth_parts = parts
|
||||
if not protocol or not host or not port:
|
||||
raise ValueError(
|
||||
"TELEGRAM_BOT_PROXY must be in format protocol(http/socks5):ip:port:user:pass"
|
||||
)
|
||||
|
||||
username = auth_parts[0] if len(auth_parts) > 0 else ""
|
||||
password = ":".join(auth_parts[1:]) if len(auth_parts) > 1 else ""
|
||||
|
||||
if username or password:
|
||||
auth = f"{quote(username, safe='')}:{quote(password, safe='')}@"
|
||||
else:
|
||||
auth = ""
|
||||
|
||||
return f"{protocol}://{auth}{host}:{port}"
|
||||
|
||||
|
||||
telegram_proxy_url = build_telegram_proxy_url(
|
||||
config("TELEGRAM_BOT_PROXY", default="")
|
||||
)
|
||||
bot_session = (
|
||||
AiohttpSession(proxy=telegram_proxy_url, timeout=TELEGRAM_API_TIMEOUT)
|
||||
if telegram_proxy_url
|
||||
else AiohttpSession(timeout=TELEGRAM_API_TIMEOUT)
|
||||
)
|
||||
|
||||
if telegram_proxy_url:
|
||||
logger.info(
|
||||
"Telegram Bot API proxy enabled for host %s",
|
||||
telegram_proxy_url.rsplit("@", 1)[-1],
|
||||
)
|
||||
|
||||
redis_url = config("REDIS_URL")
|
||||
bot = Bot(
|
||||
token=config("TOKEN"),
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
session=bot_session,
|
||||
)
|
||||
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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,133 @@
|
||||
# Aiogram imports
|
||||
import logging
|
||||
|
||||
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()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@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 Exception:
|
||||
logger.exception("Mailer preview failed")
|
||||
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 Exception:
|
||||
logger.exception("Mailer copy failed for user_id=%s", user_id)
|
||||
|
||||
await message.answer(
|
||||
text=f"✅ Рассылка завершена! Сообщение отправлено {counter}/{len(all_users)}."
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,28 @@
|
||||
# Aiogram
|
||||
import aiogram.types as types
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram import Router, F
|
||||
|
||||
# Const
|
||||
from create_bot import orm
|
||||
|
||||
# States
|
||||
from states.admin_states import AdminStates
|
||||
|
||||
# Init
|
||||
admin_statistic_router = Router()
|
||||
|
||||
|
||||
@admin_statistic_router.message(
|
||||
F.text == "📊 Статистика", StateFilter(AdminStates.main)
|
||||
)
|
||||
async def cmd_statistic(message: types.Message, state: FSMContext):
|
||||
|
||||
users_count = await orm.get_users_count()
|
||||
|
||||
msg_text = f"""<i>📊 Статистика</i>
|
||||
|
||||
🔹 Кол-во пользователей в боте: {users_count:,} чел."""
|
||||
|
||||
await message.answer(text=msg_text)
|
||||
@@ -0,0 +1,506 @@
|
||||
import aiogram.types as types
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
from create_bot import orm, tz
|
||||
from keyboards.reply_keyboards import (
|
||||
CATEGORY_BY_TEXT,
|
||||
USER_TYPE_BY_TEXT,
|
||||
USER_TYPE_LABEL_BY_CODE,
|
||||
get_back_to_menu_kb,
|
||||
get_categories_kb,
|
||||
get_consultation_actions_ikb,
|
||||
get_consultations_ikb,
|
||||
get_delete_profile_kb,
|
||||
get_main_menu_kb,
|
||||
get_profile_kb,
|
||||
get_user_types_kb,
|
||||
)
|
||||
from states.client_states import AskQuestionStates, MainStates, ProfileStates
|
||||
from utils.rag_api import ask_rag_answer
|
||||
from utils.text_tools import format_llm_answer_html, split_plain_text_chunks, to_html
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
client_router = Router()
|
||||
|
||||
DAILY_CONSULTATION_LIMIT = 5
|
||||
CONSULTATION_MESSAGE_LIMIT = 5
|
||||
|
||||
|
||||
def get_today_start_utc() -> datetime:
|
||||
now_local = datetime.now(tz)
|
||||
start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return start_local.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def build_start_text() -> str:
|
||||
return (
|
||||
"Здравствуйте. Я юридический ИИ-консультант по законам РФ.\n\n"
|
||||
"Я могу помочь:\n"
|
||||
"— разобраться в ситуации;\n"
|
||||
"— найти применимые нормы закона;\n"
|
||||
"— объяснить их простыми словами;\n"
|
||||
"— дать базовый план действий.\n\n"
|
||||
"Ответ носит информационный характер и не заменяет консультацию юриста."
|
||||
)
|
||||
|
||||
|
||||
def build_help_text() -> str:
|
||||
return (
|
||||
"ℹ️ Как пользоваться ботом\n\n"
|
||||
"1. Нажмите «Задать вопрос».\n"
|
||||
"2. Выберите категорию.\n"
|
||||
"3. Опишите ситуацию.\n"
|
||||
"4. Укажите регион РФ.\n"
|
||||
"5. Получите ответ с найденными источниками.\n\n"
|
||||
"Пример хорошего вопроса:\n"
|
||||
"«Работодатель не выплатил зарплату за апрель. Работаю официально, Москва. Что делать?»\n\n"
|
||||
"Бот не заменяет юриста и не гарантирует результат спора."
|
||||
)
|
||||
|
||||
|
||||
def build_profile_text(user) -> str:
|
||||
return (
|
||||
"👤 Профиль\n\n"
|
||||
f"Страна: {to_html(user.country or 'Россия')}\n"
|
||||
f"Регион: {to_html(user.region or 'не указан')}\n"
|
||||
f"Тип пользователя: {to_html(USER_TYPE_LABEL_BY_CODE.get(user.user_type, 'Физлицо'))}"
|
||||
)
|
||||
|
||||
|
||||
def build_consultations_text(consultations: list) -> str:
|
||||
lines = ["📚 Ваши консультации:\n"]
|
||||
|
||||
for index, consultation in enumerate(consultations, start=1):
|
||||
title = consultation.title or consultation.category
|
||||
lines.append(
|
||||
f"{index}. {to_html(title)} — {consultation.updated_at.astimezone(tz).strftime('%d.%m.%Y')}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_consultation_text(consultation, messages: list) -> str:
|
||||
lines = [
|
||||
f"📚 Консультация #{consultation.id}",
|
||||
"",
|
||||
f"Категория: {to_html(consultation.category)}",
|
||||
f"Регион: {to_html(consultation.region or 'не указан')}",
|
||||
f"Статус: {to_html(consultation.status)}",
|
||||
"",
|
||||
]
|
||||
|
||||
if not messages:
|
||||
lines.append("Сообщений пока нет.")
|
||||
return "\n".join(lines)
|
||||
|
||||
for message in messages[-6:]:
|
||||
if message.role == "user":
|
||||
title = "👤 Вопрос"
|
||||
elif message.role == "assistant":
|
||||
title = "🤖 Ответ"
|
||||
else:
|
||||
title = "ℹ️ Сообщение"
|
||||
|
||||
lines.append(title)
|
||||
if message.role == "assistant":
|
||||
lines.append(format_llm_answer_html(message.content[:1800]))
|
||||
else:
|
||||
lines.append(to_html(message.content[:1800]))
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def build_fallback_error_text(error_text: str) -> str:
|
||||
if "No reliable law chunks" in error_text:
|
||||
return (
|
||||
"Я не нашел в базе надежную норму по этому вопросу. "
|
||||
"Попробуйте уточнить ситуацию, добавить детали или выбрать другую категорию."
|
||||
)
|
||||
|
||||
return error_text
|
||||
|
||||
|
||||
async def show_main_menu(message: types.Message, state: FSMContext) -> None:
|
||||
await message.answer(build_start_text(), reply_markup=get_main_menu_kb())
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
async def process_question(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
*,
|
||||
category: str,
|
||||
question: str,
|
||||
region: str,
|
||||
consultation_id: int | None = None,
|
||||
) -> None:
|
||||
user = await orm.get_user(message.from_user.id)
|
||||
if user is None:
|
||||
await show_main_menu(message, state)
|
||||
return
|
||||
|
||||
if consultation_id is None:
|
||||
consultations_today = await orm.count_user_consultations_since(
|
||||
user_id=message.from_user.id,
|
||||
since=get_today_start_utc(),
|
||||
)
|
||||
if consultations_today >= DAILY_CONSULTATION_LIMIT:
|
||||
await message.answer(
|
||||
"На сегодня достигнут лимит: 5 новых консультаций. Попробуйте завтра.",
|
||||
reply_markup=get_main_menu_kb(),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
return
|
||||
else:
|
||||
message_count = await orm.count_user_messages_in_consultation(consultation_id)
|
||||
if message_count >= CONSULTATION_MESSAGE_LIMIT:
|
||||
await message.answer(
|
||||
"В этой консультации уже достигнут лимит: 5 сообщений пользователя. "
|
||||
"Создайте новую консультацию.",
|
||||
reply_markup=get_main_menu_kb(),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
return
|
||||
|
||||
history = []
|
||||
if consultation_id is not None:
|
||||
history = await orm.get_consultation_messages(consultation_id)
|
||||
|
||||
await state.set_state(AskQuestionStates.processing)
|
||||
await message.answer("⏳ Ищу подходящие нормы закона и готовлю ответ...")
|
||||
|
||||
try:
|
||||
response = await ask_rag_answer(
|
||||
user_id=message.from_user.id,
|
||||
consultation_id=consultation_id,
|
||||
question=question,
|
||||
category=category,
|
||||
region=region,
|
||||
user_type=user.user_type,
|
||||
history=history,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
await message.answer(
|
||||
build_fallback_error_text(str(exc)),
|
||||
reply_markup=get_main_menu_kb(),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
return
|
||||
|
||||
if user.region != region:
|
||||
await orm.set_user_region(message.from_user.id, region)
|
||||
|
||||
raw_answer_text = response.get("answer", "Не удалось сформировать ответ.")
|
||||
answer_chunks = [
|
||||
format_llm_answer_html(chunk)
|
||||
for chunk in split_plain_text_chunks(raw_answer_text)
|
||||
]
|
||||
saved_consultation_id = response.get("consultation_id")
|
||||
|
||||
for index, answer_chunk in enumerate(answer_chunks):
|
||||
reply_markup = None
|
||||
if index == len(answer_chunks) - 1 and saved_consultation_id is not None:
|
||||
reply_markup = get_consultation_actions_ikb(saved_consultation_id)
|
||||
await message.answer(answer_chunk, reply_markup=reply_markup)
|
||||
await message.answer(
|
||||
"Можете задать новый вопрос или открыть историю консультаций.",
|
||||
reply_markup=get_main_menu_kb(),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
@client_router.message(F.text == "↩️ В меню", StateFilter("*"))
|
||||
async def return_to_main_menu(message: types.Message, state: FSMContext):
|
||||
await show_main_menu(message, state)
|
||||
|
||||
|
||||
@client_router.message(F.text == "ℹ️ Помощь", StateFilter("*"))
|
||||
async def show_help(message: types.Message, state: FSMContext):
|
||||
await message.answer(build_help_text(), reply_markup=get_main_menu_kb())
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
@client_router.message(F.text == "⚖️ Задать вопрос", StateFilter("*"))
|
||||
async def ask_question_entry(message: types.Message, state: FSMContext):
|
||||
await message.answer(
|
||||
"Выберите категорию вопроса:",
|
||||
reply_markup=get_categories_kb(),
|
||||
)
|
||||
await state.set_state(AskQuestionStates.choosing_category)
|
||||
|
||||
|
||||
@client_router.message(
|
||||
F.text.in_(list(CATEGORY_BY_TEXT.keys())),
|
||||
AskQuestionStates.choosing_category,
|
||||
)
|
||||
async def set_question_category(message: types.Message, state: FSMContext):
|
||||
await state.update_data(
|
||||
category_text=message.text,
|
||||
)
|
||||
await message.answer(
|
||||
"Опишите ситуацию одним сообщением.\n\n"
|
||||
"Постарайтесь указать:\n"
|
||||
"— что произошло;\n"
|
||||
"— когда произошло;\n"
|
||||
"— с кем спор;\n"
|
||||
"— чего вы хотите добиться.",
|
||||
reply_markup=get_back_to_menu_kb(),
|
||||
)
|
||||
await state.set_state(AskQuestionStates.waiting_question)
|
||||
|
||||
|
||||
@client_router.message(AskQuestionStates.waiting_question)
|
||||
async def set_question_text(message: types.Message, state: FSMContext):
|
||||
question = (message.text or "").strip()
|
||||
if not question:
|
||||
await message.answer("Опишите ситуацию текстом одним сообщением.")
|
||||
return
|
||||
|
||||
user = await orm.get_user(message.from_user.id)
|
||||
|
||||
await state.update_data(question=question)
|
||||
|
||||
if user and user.region:
|
||||
await message.answer(
|
||||
"Укажите регион РФ, где произошла ситуация.\n\n"
|
||||
f"Сейчас в профиле указан регион: {to_html(user.region)}\n"
|
||||
"Если он подходит, отправьте символ: -",
|
||||
reply_markup=get_back_to_menu_kb(),
|
||||
)
|
||||
else:
|
||||
await message.answer(
|
||||
"Укажите регион РФ, где произошла ситуация.\n\n"
|
||||
"Например:\n"
|
||||
"Москва\n"
|
||||
"Санкт-Петербург\n"
|
||||
"Краснодарский край\n"
|
||||
"Республика Татарстан",
|
||||
reply_markup=get_back_to_menu_kb(),
|
||||
)
|
||||
|
||||
await state.set_state(AskQuestionStates.waiting_region)
|
||||
|
||||
|
||||
@client_router.message(AskQuestionStates.waiting_region)
|
||||
async def set_region_and_process(message: types.Message, state: FSMContext):
|
||||
payload = await state.get_data()
|
||||
user = await orm.get_user(message.from_user.id)
|
||||
region = (message.text or "").strip()
|
||||
|
||||
if region == "-" and user and user.region:
|
||||
region = user.region
|
||||
|
||||
if not region:
|
||||
await message.answer("Укажите регион РФ текстом.")
|
||||
return
|
||||
|
||||
await process_question(
|
||||
message,
|
||||
state,
|
||||
category=payload["category_text"],
|
||||
question=payload["question"],
|
||||
region=region,
|
||||
)
|
||||
|
||||
|
||||
@client_router.message(F.text == "📚 Мои консультации", StateFilter("*"))
|
||||
async def show_consultations(message: types.Message, state: FSMContext):
|
||||
consultations = await orm.list_user_consultations(message.from_user.id)
|
||||
|
||||
if not consultations:
|
||||
await message.answer(
|
||||
"У вас пока нет консультаций.",
|
||||
reply_markup=get_main_menu_kb(),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
build_consultations_text(consultations),
|
||||
reply_markup=get_consultations_ikb(consultations),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
@client_router.callback_query(F.data.startswith("consultation:open:"))
|
||||
async def open_consultation(callback: types.CallbackQuery, state: FSMContext):
|
||||
consultation_id = int(callback.data.rsplit(":", 1)[-1])
|
||||
consultation = await orm.get_consultation(consultation_id, callback.from_user.id)
|
||||
|
||||
if consultation is None:
|
||||
await callback.answer("Консультация не найдена.", show_alert=True)
|
||||
return
|
||||
|
||||
messages = await orm.get_consultation_messages(consultation_id)
|
||||
await callback.message.answer(
|
||||
build_consultation_text(consultation, messages),
|
||||
reply_markup=get_consultation_actions_ikb(consultation_id),
|
||||
)
|
||||
await callback.answer()
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
@client_router.callback_query(F.data.startswith("consultation:continue:"))
|
||||
async def continue_consultation(callback: types.CallbackQuery, state: FSMContext):
|
||||
consultation_id = int(callback.data.rsplit(":", 1)[-1])
|
||||
consultation = await orm.get_consultation(consultation_id, callback.from_user.id)
|
||||
|
||||
if consultation is None:
|
||||
await callback.answer("Консультация не найдена.", show_alert=True)
|
||||
return
|
||||
|
||||
await state.update_data(
|
||||
consultation_id=consultation.id,
|
||||
category_text=consultation.category,
|
||||
region=consultation.region,
|
||||
)
|
||||
await callback.message.answer(
|
||||
"Напишите следующий вопрос по этой консультации одним сообщением.",
|
||||
reply_markup=get_back_to_menu_kb(),
|
||||
)
|
||||
await callback.answer()
|
||||
await state.set_state(AskQuestionStates.waiting_continue_question)
|
||||
|
||||
|
||||
@client_router.callback_query(F.data.startswith("consultation:delete:"))
|
||||
async def delete_consultation(callback: types.CallbackQuery, state: FSMContext):
|
||||
consultation_id = int(callback.data.rsplit(":", 1)[-1])
|
||||
await orm.delete_consultation(consultation_id, callback.from_user.id)
|
||||
consultations = await orm.list_user_consultations(callback.from_user.id)
|
||||
|
||||
await callback.answer("Консультация удалена.")
|
||||
|
||||
if not consultations:
|
||||
await callback.message.answer(
|
||||
"Консультаций больше не осталось.",
|
||||
reply_markup=get_main_menu_kb(),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
return
|
||||
|
||||
await callback.message.answer(
|
||||
build_consultations_text(consultations),
|
||||
reply_markup=get_consultations_ikb(consultations),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
@client_router.message(AskQuestionStates.waiting_continue_question)
|
||||
async def process_continue_question(message: types.Message, state: FSMContext):
|
||||
question = (message.text or "").strip()
|
||||
if not question:
|
||||
await message.answer("Напишите вопрос текстом одним сообщением.")
|
||||
return
|
||||
|
||||
payload = await state.get_data()
|
||||
consultation_id = payload.get("consultation_id")
|
||||
consultation = await orm.get_consultation(consultation_id, message.from_user.id)
|
||||
|
||||
if consultation is None:
|
||||
await message.answer(
|
||||
"Консультация не найдена. Начните новую.",
|
||||
reply_markup=get_main_menu_kb(),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
return
|
||||
|
||||
await process_question(
|
||||
message,
|
||||
state,
|
||||
category=consultation.category,
|
||||
question=question,
|
||||
region=consultation.region or payload.get("region"),
|
||||
consultation_id=consultation.id,
|
||||
)
|
||||
|
||||
|
||||
@client_router.message(F.text == "👤 Профиль", StateFilter("*"))
|
||||
async def show_profile(message: types.Message, state: FSMContext):
|
||||
user = await orm.get_user(message.from_user.id)
|
||||
|
||||
if user is None:
|
||||
await show_main_menu(message, state)
|
||||
return
|
||||
|
||||
await message.answer(build_profile_text(user), reply_markup=get_profile_kb())
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
@client_router.message(F.text == "🌍 Изменить регион", StateFilter("*"))
|
||||
async def change_region_start(message: types.Message, state: FSMContext):
|
||||
await message.answer(
|
||||
"Напишите новый регион РФ одним сообщением.",
|
||||
reply_markup=get_back_to_menu_kb(),
|
||||
)
|
||||
await state.set_state(ProfileStates.waiting_region)
|
||||
|
||||
|
||||
@client_router.message(ProfileStates.waiting_region)
|
||||
async def change_region_finish(message: types.Message, state: FSMContext):
|
||||
region = (message.text or "").strip()
|
||||
if not region:
|
||||
await message.answer("Регион не должен быть пустым.")
|
||||
return
|
||||
|
||||
await orm.set_user_region(message.from_user.id, region)
|
||||
user = await orm.get_user(message.from_user.id)
|
||||
await message.answer(build_profile_text(user), reply_markup=get_profile_kb())
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
@client_router.message(F.text == "👔 Изменить тип пользователя", StateFilter("*"))
|
||||
async def change_user_type_start(message: types.Message, state: FSMContext):
|
||||
await message.answer(
|
||||
"Выберите тип пользователя:",
|
||||
reply_markup=get_user_types_kb(),
|
||||
)
|
||||
await state.set_state(ProfileStates.waiting_user_type)
|
||||
|
||||
|
||||
@client_router.message(
|
||||
F.text.in_(list(USER_TYPE_BY_TEXT.keys())),
|
||||
ProfileStates.waiting_user_type,
|
||||
)
|
||||
async def change_user_type_finish(message: types.Message, state: FSMContext):
|
||||
await orm.set_user_type(message.from_user.id, USER_TYPE_BY_TEXT[message.text])
|
||||
user = await orm.get_user(message.from_user.id)
|
||||
await message.answer(build_profile_text(user), reply_markup=get_profile_kb())
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
@client_router.message(F.text == "🗑 Удалить мои данные", StateFilter("*"))
|
||||
async def delete_profile_start(message: types.Message, state: FSMContext):
|
||||
await message.answer(
|
||||
"Удалить историю консультаций и сбросить профиль?",
|
||||
reply_markup=get_delete_profile_kb(),
|
||||
)
|
||||
await state.set_state(ProfileStates.confirm_delete)
|
||||
|
||||
|
||||
@client_router.message(F.text == "✅ Да, удалить", ProfileStates.confirm_delete)
|
||||
async def delete_profile_finish(message: types.Message, state: FSMContext):
|
||||
await orm.delete_user(message.from_user.id)
|
||||
await orm.create_user(
|
||||
user_id=message.from_user.id,
|
||||
username="@" + message.from_user.username if message.from_user.username else None,
|
||||
fullname=to_html(message.from_user.full_name),
|
||||
register_date=datetime.now(timezone.utc),
|
||||
)
|
||||
await message.answer(
|
||||
"Данные удалены. История очищена, профиль сброшен.",
|
||||
reply_markup=get_main_menu_kb(),
|
||||
)
|
||||
await state.set_state(MainStates.main)
|
||||
|
||||
|
||||
@client_router.message(MainStates.main)
|
||||
async def main_menu_fallback(message: types.Message):
|
||||
await message.answer(
|
||||
"Выберите действие в меню ниже.",
|
||||
reply_markup=get_main_menu_kb(),
|
||||
)
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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_main_menu_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 = (
|
||||
"Здравствуйте. Я юридический ИИ-консультант по законам РФ.\n\n"
|
||||
"Я могу помочь:\n"
|
||||
"— разобраться в ситуации;\n"
|
||||
"— найти применимые нормы закона;\n"
|
||||
"— объяснить их простыми словами;\n"
|
||||
"— дать базовый план действий.\n\n"
|
||||
"Ответ носит информационный характер и не заменяет консультацию юриста."
|
||||
)
|
||||
|
||||
await message.answer(text=msg_text, reply_markup=get_main_menu_kb())
|
||||
|
||||
await state.set_state(MainStates.main)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
# Aiogram imports
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
|
||||
@@ -0,0 +1,132 @@
|
||||
# Aiogram imports
|
||||
from aiogram.types import InlineKeyboardMarkup, KeyboardButton, ReplyKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
|
||||
from decouple import config
|
||||
import pytz
|
||||
|
||||
|
||||
QUESTION_CATEGORIES = [
|
||||
("💼 Работа", "labor"),
|
||||
("🛒 Защита прав потребителей", "consumer"),
|
||||
("🏠 Жилье / аренда", "housing"),
|
||||
("👪 Семья", "family"),
|
||||
("💰 Долги / займы", "civil"),
|
||||
("📄 Договоры", "civil"),
|
||||
("⚖️ Суд / процесс", "procedural"),
|
||||
("❓ Другое", "other"),
|
||||
]
|
||||
|
||||
USER_TYPES = [
|
||||
("Физлицо", "physical_person"),
|
||||
("ИП", "individual_entrepreneur"),
|
||||
("ООО", "company"),
|
||||
]
|
||||
|
||||
CATEGORY_BY_TEXT = {text: code for text, code in QUESTION_CATEGORIES}
|
||||
USER_TYPE_BY_TEXT = {text: code for text, code in USER_TYPES}
|
||||
USER_TYPE_LABEL_BY_CODE = {code: text for text, code in USER_TYPES}
|
||||
TZ = pytz.timezone(config("TIMEZONE"))
|
||||
|
||||
|
||||
def get_main_menu_kb() -> ReplyKeyboardMarkup:
|
||||
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_categories_kb() -> ReplyKeyboardMarkup:
|
||||
builder = ReplyKeyboardBuilder()
|
||||
|
||||
for text, _ in QUESTION_CATEGORIES:
|
||||
builder.add(KeyboardButton(text=text))
|
||||
|
||||
builder.add(KeyboardButton(text="↩️ В меню"))
|
||||
builder.adjust(1)
|
||||
|
||||
return builder.as_markup(resize_keyboard=True, is_persistent=True)
|
||||
|
||||
|
||||
def get_back_to_menu_kb() -> ReplyKeyboardMarkup:
|
||||
builder = ReplyKeyboardBuilder()
|
||||
builder.row(KeyboardButton(text="↩️ В меню"))
|
||||
return builder.as_markup(resize_keyboard=True)
|
||||
|
||||
|
||||
def get_profile_kb() -> ReplyKeyboardMarkup:
|
||||
builder = ReplyKeyboardBuilder()
|
||||
|
||||
builder.row(KeyboardButton(text="🌍 Изменить регион"))
|
||||
builder.row(KeyboardButton(text="👔 Изменить тип пользователя"))
|
||||
builder.row(KeyboardButton(text="🗑 Удалить мои данные"))
|
||||
builder.row(KeyboardButton(text="↩️ В меню"))
|
||||
|
||||
return builder.as_markup(resize_keyboard=True, is_persistent=True)
|
||||
|
||||
|
||||
def get_user_types_kb() -> ReplyKeyboardMarkup:
|
||||
builder = ReplyKeyboardBuilder()
|
||||
|
||||
for text, _ in USER_TYPES:
|
||||
builder.add(KeyboardButton(text=text))
|
||||
|
||||
builder.add(KeyboardButton(text="↩️ В меню"))
|
||||
builder.adjust(1)
|
||||
|
||||
return builder.as_markup(resize_keyboard=True)
|
||||
|
||||
|
||||
def get_delete_profile_kb() -> ReplyKeyboardMarkup:
|
||||
builder = ReplyKeyboardBuilder()
|
||||
|
||||
builder.row(KeyboardButton(text="✅ Да, удалить"))
|
||||
builder.row(KeyboardButton(text="↩️ В меню"))
|
||||
|
||||
return builder.as_markup(resize_keyboard=True)
|
||||
|
||||
|
||||
def get_consultations_ikb(consultations: list) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
for consultation in consultations:
|
||||
title = consultation.title or consultation.category
|
||||
updated_at = consultation.updated_at.astimezone(TZ).strftime("%d.%m.%Y")
|
||||
builder.row(
|
||||
ai_button(
|
||||
text=f"{title[:48]} — {updated_at}",
|
||||
callback_data=f"consultation:open:{consultation.id}",
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_consultation_actions_ikb(consultation_id: int) -> InlineKeyboardMarkup:
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
builder.row(
|
||||
ai_button(
|
||||
text="🔁 Продолжить",
|
||||
callback_data=f"consultation:continue:{consultation_id}",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
ai_button(
|
||||
text="🗑 Удалить",
|
||||
callback_data=f"consultation:delete:{consultation_id}",
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def ai_button(text: str, callback_data: str):
|
||||
from aiogram.types import InlineKeyboardButton
|
||||
|
||||
return InlineKeyboardButton(text=text, callback_data=callback_data)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
beautifulsoup4==4.13.4
|
||||
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
|
||||
httpx==0.28.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
|
||||
soupsieve==2.7
|
||||
typing_extensions==4.12.2
|
||||
urllib3==2.3.0
|
||||
uvloop==0.21.0
|
||||
yarl==1.18.3
|
||||
fastapi
|
||||
uvicorn
|
||||
pytz
|
||||
@@ -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()
|
||||
@@ -0,0 +1,20 @@
|
||||
# Aiogram imports
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
|
||||
|
||||
class MainStates(StatesGroup):
|
||||
main = State()
|
||||
|
||||
|
||||
class AskQuestionStates(StatesGroup):
|
||||
choosing_category = State()
|
||||
waiting_question = State()
|
||||
waiting_region = State()
|
||||
processing = State()
|
||||
waiting_continue_question = State()
|
||||
|
||||
|
||||
class ProfileStates(StatesGroup):
|
||||
waiting_region = State()
|
||||
waiting_user_type = State()
|
||||
confirm_delete = State()
|
||||
Binary file not shown.
@@ -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)
|
||||
@@ -0,0 +1,85 @@
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from decouple import config
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RAG_API_URL = config("RAG_API_URL", default="http://api:8080").rstrip("/")
|
||||
HTTP_TIMEOUT = httpx.Timeout(120.0, connect=20.0)
|
||||
|
||||
|
||||
def build_history_payload(messages: list) -> list[dict]:
|
||||
payload = []
|
||||
|
||||
for message in messages[-6:]:
|
||||
payload.append(
|
||||
{
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
}
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
async def ask_rag_answer(
|
||||
*,
|
||||
user_id: int,
|
||||
question: str,
|
||||
category: str | None,
|
||||
region: str | None,
|
||||
user_type: str | None,
|
||||
consultation_id: int | None = None,
|
||||
history: list | None = None,
|
||||
top_k: int = 5,
|
||||
) -> dict:
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"consultation_id": consultation_id,
|
||||
"save_history": True,
|
||||
"question": question,
|
||||
"category": category,
|
||||
"region": region,
|
||||
"user_type": user_type,
|
||||
"history": build_history_payload(history or []),
|
||||
"top_k": top_k,
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
f"{RAG_API_URL}/api/v1/rag/answer",
|
||||
json=payload,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = ""
|
||||
try:
|
||||
detail = exc.response.json().get("detail", "")
|
||||
except Exception:
|
||||
detail = exc.response.text
|
||||
|
||||
if "No reliable law chunks" in detail:
|
||||
detail = (
|
||||
"Я не нашел в базе надежную норму по этому вопросу. "
|
||||
"Попробуйте уточнить ситуацию и задать вопрос еще раз."
|
||||
)
|
||||
elif "User was not found" in detail:
|
||||
detail = (
|
||||
"Профиль пользователя не найден в базе. "
|
||||
"Нажмите /start и попробуйте еще раз."
|
||||
)
|
||||
elif "Consultation was not found" in detail:
|
||||
detail = (
|
||||
"Не удалось найти выбранную консультацию. "
|
||||
"Откройте историю заново или начните новую консультацию."
|
||||
)
|
||||
|
||||
logger.warning("RAG API returned %s: %s", exc.response.status_code, detail)
|
||||
raise RuntimeError(detail or "Сервис анализа вернул ошибку.")
|
||||
except httpx.HTTPError as exc:
|
||||
logger.exception("RAG API request failed")
|
||||
raise RuntimeError("Не удалось связаться с сервисом анализа. Попробуйте позже.") from exc
|
||||
@@ -0,0 +1,120 @@
|
||||
import html
|
||||
import re
|
||||
|
||||
|
||||
def to_html(obj):
|
||||
|
||||
return html.escape(str(obj))
|
||||
|
||||
|
||||
def format_llm_answer_html(text: str | None) -> str:
|
||||
if text is None:
|
||||
return ""
|
||||
|
||||
escaped = html.escape(str(text).replace("\r\n", "\n").strip())
|
||||
normalized_lines = []
|
||||
|
||||
for line in escaped.split("\n"):
|
||||
normalized_line = re.sub(r"^\s*[-*]\s+", "• ", line.rstrip())
|
||||
normalized_lines.append(normalized_line)
|
||||
|
||||
formatted = "\n".join(normalized_lines)
|
||||
formatted = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", formatted)
|
||||
return formatted
|
||||
|
||||
|
||||
def split_plain_text_chunks(text: str | None, limit: int = 3500) -> list[str]:
|
||||
if text is None:
|
||||
return [""]
|
||||
|
||||
normalized = str(text).replace("\r\n", "\n").strip()
|
||||
if not normalized:
|
||||
return [""]
|
||||
|
||||
paragraphs = normalized.split("\n\n")
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
|
||||
for paragraph in paragraphs:
|
||||
paragraph = paragraph.strip()
|
||||
if not paragraph:
|
||||
continue
|
||||
|
||||
candidate = paragraph if not current else f"{current}\n\n{paragraph}"
|
||||
if len(candidate) <= limit:
|
||||
current = candidate
|
||||
continue
|
||||
|
||||
if current:
|
||||
chunks.append(current)
|
||||
current = ""
|
||||
|
||||
if len(paragraph) <= limit:
|
||||
current = paragraph
|
||||
continue
|
||||
|
||||
lines = paragraph.split("\n")
|
||||
line_buffer = ""
|
||||
|
||||
for line in lines:
|
||||
line = line.rstrip()
|
||||
line_candidate = line if not line_buffer else f"{line_buffer}\n{line}"
|
||||
if len(line_candidate) <= limit:
|
||||
line_buffer = line_candidate
|
||||
continue
|
||||
|
||||
if line_buffer:
|
||||
chunks.append(line_buffer)
|
||||
line_buffer = ""
|
||||
|
||||
while len(line) > limit:
|
||||
chunks.append(line[:limit].rstrip())
|
||||
line = line[limit:].lstrip()
|
||||
|
||||
line_buffer = line
|
||||
|
||||
if line_buffer:
|
||||
current = line_buffer
|
||||
|
||||
if current:
|
||||
chunks.append(current)
|
||||
|
||||
return chunks or [normalized[:limit]]
|
||||
|
||||
|
||||
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
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user