Compare commits

...

6 Commits

33 changed files with 1082 additions and 686 deletions

View File

@@ -1,12 +1,13 @@
## ⭐️ Примеры
- [Эхо бот](https://github.com/love-apples/maxapi/blob/main/examples/echo/main.py)
- [Обработчик доступных событий](https://github.com/love-apples/maxapi/blob/main/examples/events/main.py)
- [Обработчики с MagicFilter](https://github.com/love-apples/maxapi/blob/main/examples/magic_filters/main.py)
- [Демонстрация роутинга, InputMedia и механика контекста](https://github.com/love-apples/maxapi/tree/main/examples/router_with_input_media) (audio.mp3 для команды /media)
- [Получение ID](https://github.com/love-apples/maxapi/tree/main/examples/get_ids/main.py)
- [Миддлварь в хендлерах](https://github.com/love-apples/maxapi/tree/main/examples/middleware_in_handlers/main.py)
- [Вебхуки](https://github.com/love-apples/maxapi/tree/main/examples/webhook)
- [Клавиатуры](https://github.com/love-apples/maxapi/tree/main/examples/keyboard/main.py)
- [Миддлварь в роутерах](https://github.com/love-apples/maxapi/tree/main/examples/middleware_for_router/main.py)
- [BaseFilter](https://github.com/love-apples/maxapi/tree/main/examples/base_filter/main.py)
- [Обработчик доступных событий](https://github.com/love-apples/maxapi/blob/main/examples/events/main.py)
- [Обработчики с MagicFilter](https://github.com/love-apples/maxapi/blob/main/examples/magic_filters/main.py)
- [Демонстрация роутинга, InputMedia и механика контекста](https://github.com/love-apples/maxapi/tree/main/examples/router_with_input_media) (audio.mp3 для команды /media)
- [Получение ID](https://github.com/love-apples/maxapi/tree/main/examples/get_ids/main.py)
- [Миддлварь в хендлерах](https://github.com/love-apples/maxapi/tree/main/examples/middleware_in_handlers/main.py)
- [Вебхуки](https://github.com/love-apples/maxapi/tree/main/examples/webhook)
- [Клавиатуры](https://github.com/love-apples/maxapi/tree/main/examples/keyboard/main.py)
- [Миддлварь в роутерах](https://github.com/love-apples/maxapi/tree/main/examples/middleware_for_router/main.py)
- [Свой фильтр на BaseFilter](https://github.com/love-apples/maxapi/tree/main/examples/base_filter/main.py)
- [Фильтр callback payload](https://github.com/love-apples/maxapi/tree/main/examples/callback_payload/main.py)

View File

@@ -0,0 +1,61 @@
import asyncio
import logging
from maxapi import Bot, Dispatcher, F
from maxapi.filters.callback_payload import CallbackPayload
from maxapi.filters.command import CommandStart
from maxapi.types import (
CallbackButton,
MessageCreated,
MessageCallback,
)
from maxapi.utils.inline_keyboard import InlineKeyboardBuilder
logging.basicConfig(level=logging.INFO)
bot = Bot('тут_ваш_токен')
dp = Dispatcher()
class MyPayload(CallbackPayload, prefix='mypayload'):
foo: str
action: str
class AnotherPayload(CallbackPayload, prefix='another'):
bar: str
value: int
@dp.message_created(CommandStart())
async def show_keyboard(event: MessageCreated):
kb = InlineKeyboardBuilder()
kb.row(
CallbackButton(
text='Первая кнопка',
payload=MyPayload(foo='123', action='edit').pack(),
),
CallbackButton(
text='Вторая кнопка',
payload=AnotherPayload(bar='abc', value=42).pack(),
),
)
await event.message.answer('Нажми кнопку!', attachments=[kb.as_markup()])
@dp.message_callback(MyPayload.filter(F.foo == '123'))
async def on_first_callback(event: MessageCallback, payload: MyPayload):
await event.answer(new_text=f'Первая кнопка: foo={payload.foo}, action={payload.action}')
@dp.message_callback(AnotherPayload.filter())
async def on_second_callback(event: MessageCallback, payload: AnotherPayload):
await event.answer(new_text=f'Вторая кнопка: bar={payload.bar}, value={payload.value}')
async def main():
await dp.start_polling(bot)
if __name__ == '__main__':
asyncio.run(main())

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,27 @@
from aiohttp import ClientTimeout
class DefaultConnectionProperties:
'''
Класс для хранения параметров соединения по умолчанию для aiohttp-клиента.
Args:
timeout (int): Таймаут всего соединения в секундах (по умолчанию 5 * 30).
sock_connect (int): Таймаут установки TCP-соединения в секундах (по умолчанию 30).
**kwargs: Дополнительные параметры, которые будут сохранены как есть.
Attributes:
timeout (ClientTimeout): Экземпляр aiohttp.ClientTimeout с заданными параметрами.
kwargs (dict): Дополнительные параметры.
'''
def __init__(self, timeout: int = 5 * 30, sock_connect: int = 30, **kwargs):
'''
Инициализация параметров соединения.
Args:
timeout (int): Таймаут всего соединения в секундах.
sock_connect (int): Таймаут установки TCP-соединения в секундах.
**kwargs: Дополнительные параметры.
'''
self.timeout = ClientTimeout(total=timeout, sock_connect=sock_connect)
self.kwargs = kwargs
self.kwargs = kwargs

View File

@@ -30,9 +30,7 @@ class BaseConnection:
"""
Базовый класс для всех методов API.
Содержит общую логику выполнения запроса (например, сериализацию, отправку HTTP-запроса, обработку ответа).
Метод request() может быть переопределён в потомках при необходимости.
Содержит общую логику выполнения запроса (сериализация, отправка HTTP-запроса, обработка ответа).
"""
API_URL = 'https://botapi.max.ru'
@@ -41,53 +39,67 @@ class BaseConnection:
AFTER_MEDIA_INPUT_DELAY = 2.0
def __init__(self) -> None:
"""
Инициализация BaseConnection.
Атрибуты:
bot (Optional[Bot]): Экземпляр бота.
session (Optional[ClientSession]): aiohttp-сессия.
after_input_media_delay (float): Задержка после ввода медиа.
"""
self.bot: Optional[Bot] = None
self.session: Optional[ClientSession] = None
self.after_input_media_delay: float = self.AFTER_MEDIA_INPUT_DELAY
async def request(
self,
method: HTTPMethod,
path: ApiPath | str,
model: BaseModel | Any = None,
is_return_raw: bool = False,
**kwargs
):
self,
method: HTTPMethod,
path: ApiPath | str,
model: BaseModel | Any = None,
is_return_raw: bool = False,
**kwargs
):
"""
Выполняет HTTP-запрос к API, используя указанные параметры.
Выполняет HTTP-запрос к API.
:param method: HTTP-метод запроса (GET, POST и т.д.)
:param path: Путь к конечной точке API
:param model: Pydantic-модель, в которую будет десериализован ответ (если is_return_raw=False)
:param is_return_raw: Если True — вернуть "сырое" тело ответа, иначе — результат десериализации в model
:param kwargs: Дополнительные параметры (например, query, headers, json)
Args:
method (HTTPMethod): HTTP-метод (GET, POST и т.д.).
path (ApiPath | str): Путь до конечной точки.
model (BaseModel | Any, optional): Pydantic-модель для десериализации ответа, если is_return_raw=False.
is_return_raw (bool, optional): Если True — вернуть сырой ответ, иначе — результат десериализации.
**kwargs: Дополнительные параметры (query, headers, json).
:return:
- Объект model (если is_return_raw=False и model задан)
- dict (если is_return_raw=True)
Returns:
model | dict | Error: Объект модели, dict или ошибка.
Raises:
RuntimeError: Если бот не инициализирован.
MaxConnection: Ошибка соединения.
InvalidToken: Ошибка авторизации (401).
"""
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
if not self.bot.session:
self.bot.session = ClientSession(
base_url=self.bot.API_URL,
timeout=self.bot.default_connection.timeout,
base_url=self.bot.API_URL,
timeout=self.bot.default_connection.timeout,
**self.bot.default_connection.kwargs
)
try:
r = await self.bot.session.request(
method=method.value,
url=path.value if isinstance(path, ApiPath) else path,
method=method.value,
url=path.value if isinstance(path, ApiPath) else path,
**kwargs
)
except ClientConnectionError as e:
raise MaxConnection(f'Ошибка при отправке запроса: {e}')
if r.status == 401:
await self.bot.session.close()
raise InvalidToken('Неверный токен!')
@@ -97,38 +109,41 @@ class BaseConnection:
error = Error(code=r.status, raw=raw)
logger_bot.error(error)
return error
raw = await r.json()
if is_return_raw:
if is_return_raw:
return raw
model = model(**raw) # type: ignore
model = model(**raw) # type: ignore
if hasattr(model, 'message'):
attr = getattr(model, 'message')
if hasattr(attr, 'bot'):
attr.bot = self.bot
if hasattr(model, 'bot'):
model.bot = self.bot
return model
async def upload_file(
self,
url: str,
path: str,
type: UploadType
self,
url: str,
path: str,
type: UploadType
):
"""
Загружает файл на указанный URL.
Загружает файл на сервер.
:param url: Конечная точка загрузки файла
:param path: Путь к локальному файлу
:param type: Тип файла (video, image, audio, file)
Args:
url (str): URL загрузки.
path (str): Путь к файлу.
type (UploadType): Тип файла.
:return: Сырой .text() ответ от сервера после загрузки файла
Returns:
str: Сырой .text() ответ от сервера.
"""
async with aiofiles.open(path, 'rb') as f:
@@ -147,12 +162,12 @@ class BaseConnection:
async with ClientSession() as session:
response = await session.post(
url=url,
url=url,
data=form
)
return await response.text()
async def upload_file_buffer(
self,
filename: str,
@@ -160,16 +175,20 @@ class BaseConnection:
buffer: bytes,
type: UploadType
):
"""
Загружает файл из буфера.
:param url: Конечная точка загрузки файла
:param buffer: Буфер (bytes)
:param type: Тип файла (video, image, audio, file)
Args:
filename (str): Имя файла.
url (str): URL загрузки.
buffer (bytes): Буфер данных.
type (UploadType): Тип файла.
:return: Сырой .text() ответ от сервера после загрузки файла
Returns:
str: Сырой .text() ответ от сервера.
"""
try:
matches = puremagic.magic_string(buffer[:4096])
if matches:
@@ -194,36 +213,7 @@ class BaseConnection:
async with ClientSession() as session:
response = await session.post(
url=url,
url=url,
data=form
)
return await response.text()
async def download_file(
self,
path: str,
url: str,
token: str,
):
"""
Скачивает медиа с указанной ссылки по токену, сохраняя по определенному пути
:param path: Путь сохранения медиа
:param url: Ссылка на медиа
:param token: Токен медиа
:return: Числовой статус
"""
headers = {
'Authorization': f'Bearer {token}'
}
async with ClientSession() as session:
async with session.get(url, headers=headers) as response:
if response.status == 200:
async with aiofiles.open(path, 'wb') as f:
await f.write(await response.read())
return response.status
return await response.text()

View File

@@ -1,4 +1,8 @@
from typing import List
class State:
"""
Представляет отдельное состояние в FSM-группе.
@@ -16,6 +20,7 @@ class State:
class StatesGroup:
"""
Базовый класс для описания группы состояний FSM.
@@ -23,11 +28,13 @@ class StatesGroup:
"""
@classmethod
def states(cls) -> list[str]:
def states(cls) -> List[str]:
"""
Получить список всех состояний в формате 'ИмяКласса:имя_состояния'.
:return: Список строковых представлений состояний
Returns:
Список строковых представлений состояний
"""
return [str(getattr(cls, attr)) for attr in dir(cls)

View File

@@ -8,6 +8,8 @@ from asyncio.exceptions import TimeoutError as AsyncioTimeoutError
from aiohttp import ClientConnectorError
from maxapi.exceptions.dispatcher import HandlerException
from .filters.filter import BaseFilter
from .filters.middleware import BaseMiddleware
from .filters.handler import Handler
@@ -60,9 +62,9 @@ class Dispatcher:
"""
Инициализация диспетчера.
:param router_id: Идентификатор роутера, используется при логгировании.
По умолчанию индекс зарегистированного роутера в списке
Args:
router_id (str | None): Идентификатор роутера для логов.
"""
self.router_id = router_id
@@ -132,6 +134,17 @@ class Dispatcher:
handler: Callable[[Any, dict[str, Any]], Awaitable[Any]]
) -> Callable[[Any, dict[str, Any]], Awaitable[Any]]:
"""
Формирует цепочку вызова middleware вокруг хендлера.
Args:
middlewares (list[BaseMiddleware]): Список middleware.
handler (Callable): Финальный обработчик.
Returns:
Callable: Обёрнутый обработчик.
"""
for mw in reversed(middlewares):
handler = functools.partial(mw, handler)
@@ -142,7 +155,8 @@ class Dispatcher:
"""
Добавляет указанные роутеры в диспетчер.
:param routers: Роутеры для добавления.
Args:
*routers (Router): Роутеры для добавления.
"""
self.routers += [r for r in routers]
@@ -150,9 +164,10 @@ class Dispatcher:
def outer_middleware(self, middleware: BaseMiddleware) -> None:
"""
Добавляет Middleware на первое место в списке
:param: middleware: Middleware
Добавляет Middleware на первое место в списке.
Args:
middleware (BaseMiddleware): Middleware.
"""
self.middlewares.insert(0, middleware)
@@ -160,9 +175,10 @@ class Dispatcher:
def middleware(self, middleware: BaseMiddleware) -> None:
"""
Добавляет Middleware в список
:param middleware: Middleware
Добавляет Middleware в конец списка.
Args:
middleware (BaseMiddleware): Middleware.
"""
self.middlewares.append(middleware)
@@ -170,9 +186,10 @@ class Dispatcher:
def filter(self, base_filter: BaseFilter) -> None:
"""
Добавляет фильтр в список
:param base_filter: Фильтр
Добавляет фильтр в список.
Args:
base_filter (BaseFilter): Фильтр.
"""
self.base_filters.append(base_filter)
@@ -182,7 +199,8 @@ class Dispatcher:
"""
Подготавливает диспетчер: сохраняет бота, регистрирует обработчики, вызывает on_started.
:param bot: Экземпляр бота.
Args:
bot (Bot): Экземпляр бота.
"""
self.bot = bot
@@ -208,11 +226,14 @@ class Dispatcher:
def __get_memory_context(self, chat_id: int, user_id: int):
"""
Возвращает существующий или создает новый контекст по chat_id и user_id.
Возвращает существующий или создаёт новый MemoryContext по chat_id и user_id.
:param chat_id: Идентификатор чата.
:param user_id: Идентификатор пользователя.
:return: Объект MemoryContext.
Args:
chat_id (int): Идентификатор чата.
user_id (int): Идентификатор пользователя.
Returns:
MemoryContext: Контекст.
"""
for ctx in self.contexts:
@@ -223,10 +244,23 @@ class Dispatcher:
self.contexts.append(new_ctx)
return new_ctx
async def call_handler(self, handler, event_object, data):
async def call_handler(
self,
handler: Callable[[Any, dict[str, Any]], Awaitable[Any]],
event_object: UpdateType,
data: Dict[str, Any]
):
"""
Правка аргументов конечной функции хендлера и ее вызов
Вызывает хендлер с нужными аргументами.
Args:
handler: Handler.
event_object: Объект события.
data: Данные для хендлера.
Returns:
None
"""
func_args = handler.func_event.__annotations__.keys()
@@ -243,6 +277,17 @@ class Dispatcher:
filters: List[BaseFilter]
) -> Optional[Dict[str, Any]] | Literal[False]:
"""
Асинхронно применяет фильтры к событию.
Args:
event (UpdateUnion): Событие.
filters (List[BaseFilter]): Список фильтров.
Returns:
Optional[Dict[str, Any]] | Literal[False]: Словарь с результатом или False.
"""
data = {}
for _filter in filters:
@@ -259,9 +304,10 @@ class Dispatcher:
async def handle(self, event_object: UpdateUnion):
"""
Основной обработчик события. Применяет фильтры, middleware и вызывает подходящий handler.
Основной обработчик события. Применяет фильтры, middleware и вызывает нужный handler.
:param event_object: Событие, пришедшее в бот.
Args:
event_object (UpdateUnion): Событие.
"""
try:
@@ -335,26 +381,36 @@ class Dispatcher:
)
kwargs_filtered = {k: v for k, v in kwargs.items() if k in func_args}
await handler_chain(event_object, kwargs_filtered)
try:
await handler_chain(event_object, kwargs_filtered)
except:
raise HandlerException(
handler_title=handler.func_event.__name__,
memory_context={
'data': await memory_context.get_data(),
'state': current_state
}
)
logger_dp.info(f'Обработано: {router_id} | {process_info}')
logger_dp.info(f'Обработано: router_id: {router_id} | {process_info}')
is_handled = True
break
if not is_handled:
logger_dp.info(f'Проигнорировано: {router_id} | {process_info}')
logger_dp.info(f'Проигнорировано: router_id: {router_id} | {process_info}')
except Exception as e:
logger_dp.error(f"Ошибка при обработке события: {router_id} | {process_info} | {e} ")
logger_dp.error(f"Ошибка при обработке события: router_id: {router_id} | {process_info} | {e} ")
async def start_polling(self, bot: Bot):
"""
Запускает цикл получения обновлений с сервера (long polling).
Запускает цикл получения обновлений (long polling).
:param bot: Экземпляр бота.
Args:
bot (Bot): Экземпляр бота.
"""
self.polling = True
@@ -399,9 +455,10 @@ class Dispatcher:
"""
Запускает FastAPI-приложение для приёма обновлений через вебхук.
:param bot: Экземпляр бота.
:param host: Хост, на котором запускается сервер.
:param port: Порт сервера.
Args:
bot (Bot): Экземпляр бота.
host (str): Хост сервера.
port (int): Порт сервера.
"""
if not FASTAPI_INSTALLED:
@@ -422,19 +479,6 @@ class Dispatcher:
'\n\t pip install maxapi[webhook]'
)
# try:
# from fastapi import Request
# from fastapi.responses import JSONResponse
# except ImportError:
# raise ImportError(
# '\n\t Не установлен fastapi!'
# '\n\t Выполните команду для установки fastapi: '
# '\n\t pip install fastapi>=0.68.0'
# '\n\t Или сразу все зависимости для работы вебхука:'
# '\n\t pip install maxapi[webhook]'
# )
@self.webhook_post('/')
async def _(request: Request):
event_json = await request.json()
@@ -457,24 +501,14 @@ class Dispatcher:
async def init_serve(self, bot: Bot, host: str = 'localhost', port: int = 8080, **kwargs):
"""
Запускает сервер для обработки входящих вебхуков.
Запускает сервер для обработки вебхуков.
:param bot: Экземпляр бота.
:param host: Хост, на котором запускается сервер.
:param port: Порт сервера.
Args:
bot (Bot): Экземпляр бота.
host (str): Хост.
port (int): Порт.
"""
# try:
# from uvicorn import Config, Server
# except ImportError:
# raise ImportError(
# '\n\t Не установлен uvicorn!'
# '\n\t Выполните команду для установки uvicorn: '
# '\n\t pip install uvicorn>=0.15.0'
# '\n\t Или сразу все зависимости для работы вебхука:'
# '\n\t pip install maxapi[webhook]'
# )
if not UVICORN_INSTALLED:
raise ImportError(
'\n\t Не установлен uvicorn!'
@@ -504,10 +538,10 @@ class Router(Dispatcher):
def __init__(self, router_id: str | None = None):
"""
Инициализация диспетчера.
:param router_id: Идентификатор роутера, используется при логгировании.
По умолчанию индекс зарегистированного роутера в списке
Инициализация роутера.
Args:
router_id (str | None): Идентификатор роутера для логов.
"""
super().__init__(router_id)
@@ -524,8 +558,9 @@ class Event:
"""
Инициализирует событие-декоратор.
:param update_type: Тип события (UpdateType).
:param router: Роутер или диспетчер, в который регистрируется обработчик.
Args:
update_type (UpdateType): Тип события.
router (Dispatcher | Router): Экземпляр роутера или диспетчера.
"""
self.update_type = update_type
@@ -536,7 +571,8 @@ class Event:
"""
Регистрирует функцию как обработчик события.
:return: Исходная функция.
Returns:
Callable: Исходная функция.
"""
def decorator(func_event: Callable):

View File

@@ -0,0 +1,17 @@
class HandlerException(Exception):
def __init__(self, handler_title: str, *args, **kwargs):
self.handler_title = handler_title
self.extra = kwargs
message = f'Обработчик: {handler_title!r}'
if args:
message += f', детали: {args}'
if kwargs:
message += f', другое: {kwargs}'
super().__init__(message)

View File

@@ -1,4 +1,4 @@
class NotAvailableForDownload(BaseException):
class NotAvailableForDownload(Exception):
...

View File

@@ -1,4 +1,4 @@
class InvalidToken(BaseException):
class InvalidToken(Exception):
...

View File

@@ -1,11 +1,11 @@
class MaxConnection(BaseException):
class MaxConnection(Exception):
...
class MaxUploadFileFailed(BaseException):
class MaxUploadFileFailed(Exception):
...
class MaxIconParamsException(BaseException):
class MaxIconParamsException(Exception):
...

View File

@@ -9,14 +9,19 @@ __all__ = [
def filter_attrs(obj: object, *filters: MagicFilter) -> bool:
"""
Применяет один или несколько фильтров MagicFilter к объекту.
:param obj: Любой объект с атрибутами (например, event/message)
:param filters: Один или несколько MagicFilter выражений
:return: True, если все фильтры возвращают True, иначе False
Args:
obj (object): Объект, к которому применяются фильтры (например, event или message).
*filters (MagicFilter): Один или несколько выражений MagicFilter.
Returns:
bool: True, если все фильтры возвращают True, иначе False.
"""
try:
return all(f.resolve(obj) for f in filters)
except Exception:
return False
return False

View File

@@ -0,0 +1,175 @@
from __future__ import annotations
from typing import Any, ClassVar, List, Optional, Type, TYPE_CHECKING
from magic_filter import MagicFilter
from pydantic import BaseModel
from ..types.updates.message_callback import MessageCallback
from ..types.updates import UpdateUnion
from .filter import BaseFilter
PAYLOAD_MAX = 1024
class CallbackPayload(BaseModel):
"""
Базовый класс для сериализации/десериализации callback payload.
Атрибуты:
prefix (str): Префикс для payload (используется при pack/unpack) (по умолчанию название класса).
separator (str): Разделитель между значениями (по умолчанию '|').
"""
if TYPE_CHECKING:
prefix: ClassVar[str]
separator: ClassVar[str]
def __init_subclass__(cls, **kwargs: Any) -> None:
"""
Автоматически проставляет prefix и separator при наследовании.
"""
cls.prefix = kwargs.get('prefix', str(cls.__name__))
cls.separator = kwargs.get('separator', '|')
def pack(self) -> str:
"""
Собирает данные payload в строку для передачи в callback payload.
Raises:
ValueError: Если в значении встречается разделитель или payload слишком длинный.
Returns:
str: Сериализованный payload.
"""
values = [self.prefix]
for name in self.attrs():
value = getattr(self, name)
str_value = '' if value is None else str(value)
if self.separator in str_value:
raise ValueError(
f'Символ разделителя "{self.separator}" не должен встречаться в значении поля {name}'
)
values.append(str_value)
data = self.separator.join(values)
if len(data.encode()) > PAYLOAD_MAX:
raise ValueError(
f'Payload слишком длинный! Максимум: {PAYLOAD_MAX} байт'
)
return data
@classmethod
def unpack(cls, data: str):
"""
Десериализует payload из строки.
Args:
data (str): Строка payload (из callback payload).
Raises:
ValueError: Некорректный prefix или количество аргументов.
Returns:
CallbackPayload: Экземпляр payload с заполненными полями.
"""
parts = data.split(cls.separator)
if not parts[0] == cls.prefix:
raise ValueError('Некорректный prefix')
field_names = cls.attrs()
if not len(parts) - 1 == len(field_names):
raise ValueError(
f'Ожидалось {len(field_names)} аргументов, получено {len(parts) - 1}'
)
kwargs = dict(zip(field_names, parts[1:]))
return cls(**kwargs)
@classmethod
def attrs(cls) -> List[str]:
"""
Возвращает список полей для сериализации/десериализации (исключая prefix и separator).
Returns:
List[str]: Имена полей модели.
"""
return [
k for k in cls.model_fields.keys()
if k not in ('prefix', 'separator')
]
@classmethod
def filter(cls, rule: Optional[MagicFilter] = None) -> PayloadFilter:
"""
Создаёт PayloadFilter для фильтрации callback-ивентов по payload.
Args:
rule (Optional[MagicFilter]): Фильтр на payload.
Returns:
PayloadFilter: Экземпляр фильтра для хэндлера.
"""
return PayloadFilter(model=cls, rule=rule)
class PayloadFilter(BaseFilter):
"""
Фильтр для MessageCallback по payload.
"""
def __init__(self, model: Type[CallbackPayload], rule: Optional[MagicFilter]):
"""
Args:
model (Type[CallbackPayload]): Класс payload для распаковки.
rule (Optional[MagicFilter]): Фильтр (условие) для payload.
"""
self.model = model
self.rule = rule
async def __call__(self, event: UpdateUnion):
"""
Проверяет event на MessageCallback и применяет фильтр к payload.
Args:
event (UpdateUnion): Обновление/событие.
Returns:
dict | bool: dict с payload при совпадении, иначе False.
"""
if not isinstance(event, MessageCallback):
return False
if not event.callback.payload:
return False
try:
payload = self.model.unpack(event.callback.payload)
except Exception:
return False
if not self.rule or self.rule.resolve(payload):
return {'payload': payload}
return False

View File

@@ -12,12 +12,12 @@ class Command(BaseFilter):
Фильтр сообщений на соответствие команде.
Args:
commands (str | list[str]): Ожидаемая команда или список команд без префикса.
commands (str | List[str]): Ожидаемая команда или список команд без префикса.
prefix (str, optional): Префикс команды (по умолчанию '/').
check_case (bool, optional): Учитывать регистр при сравнении (по умолчанию False).
Attributes:
commands (list[str]): Список команд без префикса.
commands (List[str]): Список команд без префикса.
prefix (str): Префикс команды.
check_case (bool): Флаг чувствительности к регистру.
"""

View File

@@ -6,5 +6,16 @@ if TYPE_CHECKING:
class BaseFilter:
"""
Базовый класс для фильтров.
Определяет интерфейс фильтрации событий.
Потомки должны переопределять метод __call__.
Methods:
__call__(event): Асинхронная проверка события на соответствие фильтру.
"""
async def __call__(self, event: UpdateUnion) -> bool | dict:
return True
return True

View File

@@ -17,27 +17,24 @@ class Handler:
"""
Обработчик события.
Позволяет связать функцию-обработчик с типом обновления, состоянием и набором фильтров.
Связывает функцию-обработчик с типом события, состояниями и фильтрами.
"""
def __init__(
self,
*args,
func_event: Callable,
update_type: UpdateType,
**kwargs
):
self,
*args,
func_event: Callable,
update_type: UpdateType,
**kwargs
):
"""
Инициализация обработчика.
Создаёт обработчик события.
:param args: Список фильтров и состояний, в том числе:
- MagicFilter — фильтр события,
- State — состояние FSM,
- Command — команда для фильтрации по началу текста сообщения.
:param func_event: Функция-обработчик события
:param update_type: Тип обновления (события), на которое подписан обработчик
:param kwargs: Дополнительные параметры (не используются)
Args:
*args: Список фильтров (MagicFilter, State, Command, BaseFilter, BaseMiddleware).
func_event (Callable): Функция-обработчик.
update_type (UpdateType): Тип обновления.
"""
self.func_event: Callable = func_event
@@ -57,5 +54,6 @@ class Handler:
elif isinstance(arg, BaseFilter):
self.base_filters.append(arg)
else:
logger_dp.info(f'Обнаружен неизвестный фильтр `{arg}` при '
f'регистрации функции `{func_event.__name__}`')
logger_dp.info(
f'Неизвестный фильтр `{arg}` при регистрации `{func_event.__name__}`'
)

View File

@@ -1,10 +1,30 @@
from typing import Any, Callable, Awaitable
class BaseMiddleware:
"""
Базовый класс для мидлварей.
Используется для обработки события до и после вызова хендлера.
"""
async def __call__(
self,
handler: Callable[[Any, dict[str, Any]], Awaitable[Any]],
event_object: Any,
data: dict[str, Any]
) -> Any:
return await handler(event_object, data)
"""
Вызывает хендлер с переданным событием и данными.
Args:
handler (Callable): Хендлер события.
event_object (Any): Событие.
data (dict): Дополнительные данные.
Returns:
Any: Результат работы хендлера.
"""
return await handler(event_object, data)

View File

@@ -0,0 +1,25 @@
from typing import Annotated, Union
from pydantic import Field
from ..attachments.share import Share
from ..attachments.buttons.attachment_button import AttachmentButton
from ..attachments.sticker import Sticker
from ..attachments.file import File
from ..attachments.image import Image
from ..attachments.video import Video
from ..attachments.audio import Audio
from ..attachments.location import Location
from ..attachments.contact import Contact
Attachments = Annotated[Union[
Audio,
Video,
File,
Image,
Sticker,
Share,
Location,
AttachmentButton,
Contact
], Field(discriminator='type')]

View File

@@ -113,30 +113,4 @@ class Attachment(BaseModel):
bot: Optional[Bot] # type: ignore
class Config:
use_enum_values = True
# async def download(
# self,
# path: str
# ):
# """
# Скачивает медиа, сохраняя по определенному пути
# :param path: Путь сохранения медиа
# :return: Числовой статус
# """
# if not hasattr(self.payload, 'token') or \
# not hasattr(self.payload, 'url'):
# raise NotAvailableForDownload()
# elif not self.payload.token or not self.payload.url:
# raise NotAvailableForDownload(f'Медиа типа `{self.type}` недоступно для скачивания')
# return await self.bot.download_file(
# path=path,
# url=self.payload.url,
# token=self.payload.token,
# )
use_enum_values = True

View File

@@ -11,7 +11,6 @@ class Audio(Attachment):
Вложение с типом аудио.
Attributes:
type (Literal['audio']): Тип вложения, всегда 'audio'.
transcription (Optional[str]): Транскрипция аудио (если есть).
"""

View File

@@ -5,7 +5,8 @@ from .button import Button
class RequestGeoLocationButton(Button):
"""Кнопка запроса геолокации пользователя.
"""
Кнопка запроса геолокации пользователя.
Attributes:
quick: Если True, запрашивает геолокацию без дополнительного

View File

@@ -8,9 +8,6 @@ class Contact(Attachment):
"""
Вложение с типом контакта.
Attributes:
type (Literal['contact']): Тип вложения, всегда 'contact'.
"""
type: Literal[AttachmentType.CONTACT]

View File

@@ -11,7 +11,6 @@ class File(Attachment):
Вложение с типом файла.
Attributes:
type (Literal['file']): Тип вложения, всегда 'file'.
filename (Optional[str]): Имя файла.
size (Optional[int]): Размер файла в байтах.
"""

View File

@@ -11,7 +11,6 @@ class Location(Attachment):
Вложение с типом геолокации.
Attributes:
type (Literal['location']): Тип вложения, всегда 'location'.
latitude (Optional[float]): Широта.
longitude (Optional[float]): Долгота.
"""

View File

@@ -11,7 +11,6 @@ class Share(Attachment):
Вложение с типом "share" (поделиться).
Attributes:
type (Literal['share']): Тип вложения, всегда 'share'.
title (Optional[str]): Заголовок для шаринга.
description (Optional[str]): Описание.
image_url (Optional[str]): URL изображения для предпросмотра.

View File

@@ -11,7 +11,6 @@ class Sticker(Attachment):
Вложение с типом стикера.
Attributes:
type (Literal['sticker']): Тип вложения, всегда 'sticker'.
width (Optional[int]): Ширина стикера в пикселях.
height (Optional[int]): Высота стикера в пикселях.
"""

View File

@@ -51,7 +51,6 @@ class Video(Attachment):
Вложение с типом видео.
Attributes:
type (Optional[Literal['video']]): Тип вложения, всегда 'video'.
token (Optional[str]): Токен видео.
urls (Optional[VideoUrl]): URLs видео разных разрешений.
thumbnail (VideoThumbnail): Миниатюра видео.

View File

@@ -6,6 +6,7 @@ from ..enums.upload_type import UploadType
class InputMedia:
"""
Класс для представления медиафайла.
@@ -15,16 +16,19 @@ class InputMedia:
"""
def __init__(self, path: str):
"""
Инициализирует объект медиафайла.
Args:
path (str): Путь к файлу.
"""
self.path = path
self.type = self.__detect_file_type(path)
def __detect_file_type(self, path: str) -> UploadType:
"""
Определяет тип файла на основе его содержимого (MIME-типа).
@@ -34,6 +38,7 @@ class InputMedia:
Returns:
UploadType: Тип файла (VIDEO, IMAGE, AUDIO или FILE).
"""
with open(path, 'rb') as f:
sample = f.read(4096)
@@ -60,6 +65,7 @@ class InputMedia:
class InputMediaBuffer:
"""
Класс для представления медиафайла из буфера.
@@ -69,6 +75,7 @@ class InputMediaBuffer:
"""
def __init__(self, buffer: bytes, filename: str | None = None):
"""
Инициализирует объект медиафайла из буфера.
@@ -76,6 +83,7 @@ class InputMediaBuffer:
buffer (IO): Буфер с содержимым файла.
filename (str): Название файла (по умолчанию присваивается uuid4).
"""
self.filename = filename
self.buffer = buffer
self.type = self.__detect_file_type(buffer)

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import Annotated, Any, Optional, List, Union, TYPE_CHECKING
from typing import Any, Optional, List, Union, TYPE_CHECKING
from ..types.attachments.contact import Contact
from ..types.attachments import Attachments
from ..enums.text_style import TextStyle
from ..enums.parse_mode import ParseMode
@@ -11,14 +11,6 @@ from ..enums.chat_type import ChatType
from ..enums.message_link_type import MessageLinkType
from .attachments.attachment import Attachment
from .attachments.share import Share
from .attachments.buttons.attachment_button import AttachmentButton
from .attachments.sticker import Sticker
from .attachments.file import File
from .attachments.image import Image
from .attachments.video import Video
from .attachments.audio import Audio
from .attachments.location import Location
from .users import User
@@ -26,19 +18,6 @@ from .users import User
if TYPE_CHECKING:
from ..bot import Bot
from ..types.input_media import InputMedia, InputMediaBuffer
Attachments = Annotated[Union[
Audio,
Video,
File,
Image,
Sticker,
Share,
Location,
AttachmentButton,
Contact
], Field(discriminator='type')]
class MarkupElement(BaseModel):

View File

@@ -1,25 +1,18 @@
from typing import List, Optional, Union
from typing import List, Optional
from pydantic import BaseModel, Field
from ...types.attachments.location import Location
from ...types.attachments import Attachments
from .update import Update
from ...enums.parse_mode import ParseMode
from ...types.message import NewMessageLink
from ...types.attachments.share import Share
from ...types.callback import Callback
from ...types.message import Message
from ..attachments.buttons.attachment_button import AttachmentButton
from ..attachments.sticker import Sticker
from ..attachments.file import File
from ..attachments.image import Image
from ..attachments.video import Video
from ..attachments.audio import Audio
class MessageForCallback(BaseModel):
@@ -37,18 +30,7 @@ class MessageForCallback(BaseModel):
text: Optional[str] = None
attachments: Optional[
List[
Union[
AttachmentButton,
Audio,
Video,
File,
Image,
Sticker,
Share,
Location
]
]
List[Attachments]
] = Field(default_factory=list) # type: ignore
link: Optional[NewMessageLink] = None
notify: Optional[bool] = True

View File

@@ -6,6 +6,17 @@ from .update import Update
class MessageChatCreated(Update):
"""
Событие создания чата.
Attributes:
chat (Chat): Объект чата.
title (Optional[str]): Название чата.
message_id (Optional[str]): ID сообщения.
start_payload (Optional[str]): Payload для старта.
"""
chat: Chat
title: Optional[str] = None
message_id: Optional[str] = None

View File

@@ -5,7 +5,8 @@ from ..types.attachments.attachment import Attachment, ButtonsPayload
class InlineKeyboardBuilder:
"""Конструктор инлайн-клавиатур.
"""
Конструктор инлайн-клавиатур.
Позволяет удобно собирать кнопки в ряды и формировать из них клавиатуру
для отправки в сообщениях.
@@ -16,7 +17,8 @@ class InlineKeyboardBuilder:
def row(self, *buttons: InlineButtonUnion):
"""Добавить новый ряд кнопок в клавиатуру.
"""
Добавить новый ряд кнопок в клавиатуру.
Args:
*buttons: Произвольное количество кнопок для добавления в ряд.
@@ -26,7 +28,8 @@ class InlineKeyboardBuilder:
def add(self, button: InlineButtonUnion):
"""Добавить кнопку в последний ряд клавиатуры.
"""
Добавить кнопку в последний ряд клавиатуры.
Args:
button: Кнопка для добавления.
@@ -36,7 +39,8 @@ class InlineKeyboardBuilder:
def as_markup(self):
"""Собрать клавиатуру в объект для отправки.
"""
Собрать клавиатуру в объект для отправки.
Returns:
Объект вложения с типом INLINE_KEYBOARD.

View File

@@ -21,9 +21,20 @@ from ..enums.chat_type import ChatType
if TYPE_CHECKING:
from ..bot import Bot
async def enrich_event(event_object: Any, bot: Bot) -> Any:
"""
Дополняет объект события данными чата, пользователя и ссылкой на бота.
Args:
event_object (Any): Событие, которое нужно дополнить.
bot (Bot): Экземпляр бота.
Returns:
Any: Обновлённый объект события.
"""
if not bot.auto_requests:
return event_object