Compare commits

...

21 Commits

Author SHA1 Message Date
e3c17ab60f Переход на Google Style Docstring 2025-08-06 02:38:34 +03:00
af4c9dc487 Переход на Google Style Docstring 2025-08-06 02:35:33 +03:00
1400f72cd0 Добавлен HandlerException, BaseException -> Exception 2025-08-06 02:34:48 +03:00
e0569de1c5 Добавлен пример на CallbackPayload 2025-08-05 00:54:00 +03:00
ff4575fe84 Добавлен фильтр-обработка CallbackPayload 2025-08-05 00:53:45 +03:00
e922132319 Поправлено преобразование вложений для ответа на callback 2025-08-05 00:53:17 +03:00
b59d97da8a 0.9.5 2025-08-03 14:02:59 +03:00
fe68e41b7a Поправлен импорт GetMessage 2025-08-03 13:58:14 +03:00
50980dfc77 Импорт BaseFilter 2025-08-03 13:57:31 +03:00
036c92d072 Переделана основа для фильтров Command, CommandStart 2025-08-03 13:57:19 +03:00
cb2226eee5 Добавлен BaseFilter 2025-08-03 13:56:56 +03:00
3855f93862 Правки по mypy 2025-08-03 13:56:12 +03:00
01e9cdd2fd Добавлен пример с BaseFilter 2025-08-03 13:55:53 +03:00
338d9c4089 Переделано добавление Middleware 2025-08-03 13:55:31 +03:00
3fa34079ae Доработано преобразование вложений в Pydantic модели 2025-08-02 23:22:36 +03:00
5bc5fb45c8 Мелкие правки 2025-08-02 23:21:57 +03:00
95313ad3dc Переработан метод для получения одного сообщения 2025-07-31 23:38:47 +03:00
42690d24ee Добавлена обработка события dialog_removed 2025-07-31 23:30:30 +03:00
6ad3df5829 0.9.4 2025-07-30 13:29:44 +03:00
622d3a3eb3 Изменена версия aiohttp на 3.12.14 2025-07-30 13:16:42 +03:00
67de8aae1f Правки в тексте 2025-07-30 13:16:14 +03:00
47 changed files with 1488 additions and 771 deletions

View File

@@ -9,3 +9,5 @@
- [Вебхуки](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,38 @@
import asyncio
import logging
from maxapi import Bot, Dispatcher
from maxapi.types import MessageCreated, CommandStart, UpdateUnion
from maxapi.filters import BaseFilter
logging.basicConfig(level=logging.INFO)
bot = Bot(token='тут_ваш_токен')
dp = Dispatcher()
class FilterChat(BaseFilter):
"""
Фильтр, который срабатывает только в чате с названием `Test`
"""
async def __call__(self, event: UpdateUnion):
if not event.chat:
return False
return event.chat == 'Test'
@dp.message_created(CommandStart(), FilterChat())
async def custom_data(event: MessageCreated):
await event.message.answer('Привет!')
async def main():
await dp.start_polling(bot)
if __name__ == '__main__':
asyncio.run(main())

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

View File

@@ -55,12 +55,17 @@ async def hello(event: MessageCreated):
attachments=[
builder.as_markup(),
] # Для MAX клавиатура это вложение,
) # поэтому она в списке вложений
) # поэтому она в attachments
@dp.bot_added()
async def bot_added(event: BotAdded):
await event.bot.send_message(
if not event.chat:
logging.info('Не удалось получить chat, возможно отключен auto_requests!')
return
await bot.send_message(
chat_id=event.chat.id,
text=f'Привет чат {event.chat.title}!'
)
@@ -68,7 +73,7 @@ async def bot_added(event: BotAdded):
@dp.message_removed()
async def message_removed(event: MessageRemoved):
await event.bot.send_message(
await bot.send_message(
chat_id=event.chat_id,
text='Я всё видел!'
)
@@ -76,7 +81,7 @@ async def message_removed(event: MessageRemoved):
@dp.bot_started()
async def bot_started(event: BotStarted):
await event.bot.send_message(
await bot.send_message(
chat_id=event.chat_id,
text='Привет! Отправь мне /start'
)
@@ -84,9 +89,9 @@ async def bot_started(event: BotStarted):
@dp.chat_title_changed()
async def chat_title_changed(event: ChatTitleChanged):
await event.bot.send_message(
await bot.send_message(
chat_id=event.chat_id,
text=f'Крутое новое название "{event.chat.title}"!'
text=f'Крутое новое название "{event.title}"!'
)
@@ -106,7 +111,14 @@ async def message_edited(event: MessageEdited):
@dp.user_removed()
async def user_removed(event: UserRemoved):
await event.bot.send_message(
if not event.from_user:
return await bot.send_message(
chat_id=event.chat_id,
text=f'Неизвестный кикнул {event.user.first_name} 😢'
)
await bot.send_message(
chat_id=event.chat_id,
text=f'{event.from_user.first_name} кикнул {event.user.first_name} 😢'
)
@@ -114,7 +126,14 @@ async def user_removed(event: UserRemoved):
@dp.user_added()
async def user_added(event: UserAdded):
await event.bot.send_message(
if not event.chat:
return await bot.send_message(
chat_id=event.chat_id,
text=f'Чат приветствует вас, {event.user.first_name}!'
)
await bot.send_message(
chat_id=event.chat_id,
text=f'Чат "{event.chat.title}" приветствует вас, {event.user.first_name}!'
)
@@ -122,27 +141,32 @@ async def user_added(event: UserAdded):
@dp.bot_stopped()
async def bot_stopped(event: BotStopped):
print(event.from_user.full_name, 'остановил бота') # type: ignore
logging.info(event.from_user.full_name, 'остановил бота') # type: ignore
@dp.dialog_cleared()
async def dialog_cleared(event: DialogCleared):
print(event.from_user.full_name, 'очистил историю чата с ботом') # type: ignore
logging.info(event.from_user.full_name, 'очистил историю чата с ботом') # type: ignore
@dp.dialog_muted()
async def dialog_muted(event: DialogMuted):
print(event.from_user.full_name, 'отключил оповещения от чата бота до ', event.muted_until_datetime) # type: ignore
logging.info(event.from_user.full_name, 'отключил оповещения от чата бота до ', event.muted_until_datetime) # type: ignore
@dp.dialog_unmuted()
async def dialog_unmuted(event: DialogUnmuted):
print(event.from_user.full_name, 'включил оповещения от чата бота') # type: ignore
logging.info(event.from_user.full_name, 'включил оповещения от чата бота') # type: ignore
@dp.dialog_unmuted()
async def dialog_removed(event: DialogUnmuted):
logging.info(event.from_user.full_name, 'удалил диалог с ботом') # type: ignore
@dp.message_chat_created()
async def message_chat_created(event: MessageChatCreated):
await event.bot.send_message(
await bot.send_message(
chat_id=event.chat.chat_id,
text=f'Чат создан! Ссылка: {event.chat.link}'
)

View File

@@ -54,8 +54,8 @@ async def builder(event: MessageCreated):
chat_description='Test desc'
),
LinkButton(
text="Канал разработчика",
url="https://t.me/loveapples_dev"
text="Документация MAX",
url="https://dev.max.ru/docs"
),
)
@@ -99,8 +99,8 @@ async def payload(event: MessageCreated):
chat_description='Test desc'
),
LinkButton(
text="Канал разработчика",
url="https://t.me/loveapples_dev"
text="Документация MAX",
url="https://dev.max.ru/docs"
),
],
[

View File

@@ -32,9 +32,8 @@ async def custom_data(event: MessageCreated, custom_data: str):
async def main():
dp.middlewares = [
CustomDataForRouterMiddleware()
]
dp.middleware(CustomDataForRouterMiddleware())
await dp.start_polling(bot)

View File

@@ -23,7 +23,6 @@ from .methods.delete_bot_from_chat import DeleteMeFromMessage
from .methods.delete_chat import DeleteChat
from .methods.delete_message import DeleteMessage
from .methods.delete_pin_message import DeletePinMessage
# from .methods.download_media import DownloadMedia
from .methods.edit_chat import EditChat
from .methods.edit_message import EditMessage
from .methods.get_chat_by_id import GetChatById
@@ -50,6 +49,7 @@ from .methods.subscribe_webhook import SubscribeWebhook
from .methods.types.subscribed import Subscribed
from .methods.types.unsubscribed import Unsubscribed
from .methods.unsubscribe_webhook import UnsubscribeWebhook
from .methods.get_message import GetMessage
if TYPE_CHECKING:
from .types.attachments.attachment import Attachment
@@ -81,7 +81,8 @@ if TYPE_CHECKING:
class Bot(BaseConnection):
"""Основной класс для работы с API бота.
"""
Основной класс для работы с API бота.
Предоставляет методы для взаимодействия с чатами, сообщениями,
пользователями и другими функциями бота.
@@ -99,19 +100,20 @@ class Bot(BaseConnection):
):
"""
Инициализирует экземпляр бота с указанным токеном.
Инициализирует экземпляр бота.
Args:
token (str): Токен доступа к API бота.
parse_mode (Optional[ParseMode]): Форматирование по умолчанию.
notify (Optional[bool]): Отключение уведомлений при отправке сообщений.
auto_requests (bool): Автоматическое заполнение chat/from_user через API (по умолчанию True).
default_connection (Optional[DefaultConnectionProperties]): Настройки соединения.
after_input_media_delay (Optional[float]): Задержка после загрузки файла.
auto_check_subscriptions (bool): Проверка подписок для метода start_polling.
:param token: Токен доступа к API бота
:param parse_mode: Форматирование по умолчанию
:param notify: Отключение уведомлений при отправке сообщений (по умолчанию игнорируется) (не работает на стороне MAX)
:param auto_requests: Автоматическое заполнение полей chat и from_user в Update с помощью API запросов если они не заложены как полноценные объекты в Update (по умолчанию True, при False chat и from_user в некоторых событиях будут выдавать None)
:param default_connection: Настройки aiohttp
:param after_input_media_delay: Задержка в секундах после загрузки файла на сервера MAX (без этого чаще всего MAX не успевает обработать вложение и выдает ошибку `errors.process.attachment.file.not.processed`)
:param auto_check_subscriptions: Проверка на установленные подписки для метода start_polling (бот не работает в поллинге при установленных подписках)
"""
super().__init__()
self.bot = self
self.default_connection = default_connection or DefaultConnectionProperties()
self.after_input_media_delay = after_input_media_delay or 2.0
@@ -129,15 +131,53 @@ class Bot(BaseConnection):
@property
def me(self):
"""
Возвращает объект пользователя (бота).
Returns:
User | None: Объект пользователя или None.
"""
return self._me
def _resolve_notify(self, notify: Optional[bool]) -> Optional[bool]:
"""
Определяет флаг уведомления.
Args:
notify (Optional[bool]): Локальный флаг.
Returns:
Optional[bool]: Итоговый флаг.
"""
return notify if notify is not None else self.notify
def _resolve_parse_mode(self, mode: Optional[ParseMode]) -> Optional[ParseMode]:
"""
Определяет режим форматирования.
Args:
mode (Optional[ParseMode]): Локальный режим.
Returns:
Optional[ParseMode]: Итоговый режим.
"""
return mode if mode is not None else self.parse_mode
async def close_session(self) -> None:
"""
Закрывает текущую сессию aiohttp.
Returns:
None
"""
if self.session is not None:
await self.session.close()
@@ -155,15 +195,17 @@ class Bot(BaseConnection):
"""
Отправляет сообщение в чат или пользователю.
:param chat_id: ID чата для отправки (обязателен, если не указан user_id)
:param user_id: ID пользователя для отправки (обязателен, если не указан chat_id)
:param text: Текст сообщения
:param attachments: Список вложений к сообщению
:param link: Данные ссылки сообщения
:param notify: Отправлять уведомление получателю (по умолчанию берется значение из бота)
:param parse_mode: Режим форматирования текста
Args:
chat_id (Optional[int]): ID чата для отправки (если не user_id).
user_id (Optional[int]): ID пользователя (если не chat_id).
text (Optional[str]): Текст сообщения.
attachments (Optional[List[Attachment | InputMedia | InputMediaBuffer]]): Вложения.
link (Optional[NewMessageLink]): Данные ссылки сообщения.
notify (Optional[bool]): Флаг уведомления.
parse_mode (Optional[ParseMode]): Режим форматирования текста.
:return: Объект отправленного сообщения
Returns:
Optional[SendedMessage | Error]: Отправленное сообщение или ошибка.
"""
return await SendMessage(
@@ -186,10 +228,12 @@ class Bot(BaseConnection):
"""
Отправляет действие в чат (например, "печатает").
:param chat_id: ID чата для отправки действия
:param action: Тип действия (по умолчанию SenderAction.TYPING_ON)
Args:
chat_id (Optional[int]): ID чата.
action (SenderAction): Тип действия.
:return: Результат отправки действия
Returns:
SendedAction: Результат отправки действия.
"""
return await SendAction(
@@ -211,14 +255,16 @@ class Bot(BaseConnection):
"""
Редактирует существующее сообщение.
:param message_id: ID сообщения для редактирования
:param text: Новый текст сообщения
:param attachments: Новые вложения
:param link: Новая ссылка сообщения
:param notify: Отправлять уведомление получателю (по умолчанию берется значение из бота)
:param parse_mode: Режим форматирования текста
Args:
message_id (str): ID сообщения.
text (Optional[str]): Новый текст.
attachments (Optional[List[Attachment | InputMedia | InputMediaBuffer]]): Новые вложения.
link (Optional[NewMessageLink]): Новая ссылка.
notify (Optional[bool]): Флаг уведомления.
parse_mode (Optional[ParseMode]): Режим форматирования текста.
:return: Объект отредактированного сообщения
Returns:
Optional[EditedMessage | Error]: Отредактированное сообщение или ошибка.
"""
return await EditMessage(
@@ -239,9 +285,11 @@ class Bot(BaseConnection):
"""
Удаляет сообщение.
:param message_id: ID сообщения для удаления
Args:
message_id (str): ID сообщения.
:return: Результат удаления сообщения
Returns:
DeletedMessage: Результат удаления.
"""
return await DeleteMessage(
@@ -257,9 +305,11 @@ class Bot(BaseConnection):
"""
Удаляет чат.
:param chat_id: ID чата для удаления
Args:
chat_id (int): ID чата.
:return: Результат удаления чата
Returns:
DeletedChat: Результат удаления чата.
"""
return await DeleteChat(
@@ -279,13 +329,15 @@ class Bot(BaseConnection):
"""
Получает сообщения из чата.
:param chat_id: ID чата (обязателен, если не указаны message_ids)
:param message_ids: Список ID сообщений для получения
:param from_time: Время начала периода (datetime или timestamp)
:param to_time: Время конца периода (datetime или timestamp)
:param count: Количество сообщений (по умолчанию 50)
Args:
chat_id (Optional[int]): ID чата.
message_ids (Optional[List[str]]): ID сообщений.
from_time (Optional[datetime | int]): Начало периода.
to_time (Optional[datetime | int]): Конец периода.
count (int): Количество сообщений.
:return: Список сообщений
Returns:
Messages: Список сообщений.
"""
return await GetMessages(
@@ -300,26 +352,30 @@ class Bot(BaseConnection):
async def get_message(
self,
message_id: str
) -> Messages:
) -> Message:
"""
Получает одно сообщение по ID.
:param message_id: ID сообщения
Args:
message_id (str): ID сообщения.
:return: Объект сообщения
Returns:
Message: Объект сообщения.
"""
return await self.get_messages(
message_ids=[message_id]
)
return await GetMessage(
bot=self,
message_id=message_id
).fetch()
async def get_me(self) -> User:
"""
Получает информацию о текущем боте.
:return: Объект пользователя бота
Returns:
User: Объект пользователя бота.
"""
return await GetMe(self).fetch()
@@ -332,9 +388,11 @@ class Bot(BaseConnection):
"""
Получает закрепленное сообщение в чате.
:param chat_id: ID чата
Args:
chat_id (int): ID чата.
:return: Закрепленное сообщение
Returns:
GettedPin: Закрепленное сообщение.
"""
return await GetPinnedMessage(
@@ -353,12 +411,14 @@ class Bot(BaseConnection):
"""
Изменяет информацию о боте.
:param name: Новое имя бота
:param description: Новое описание бота
:param commands: Список команд бота
:param photo: Данные фотографии бота
Args:
name (Optional[str]): Новое имя бота.
description (Optional[str]): Новое описание.
commands (Optional[List[BotCommand]]): Команды бота.
photo (Optional[PhotoAttachmentRequestPayload]): Фото бота.
:return: Обновленная информация о боте
Returns:
User: Обновленная информация о боте.
"""
return await ChangeInfo(
@@ -378,10 +438,12 @@ class Bot(BaseConnection):
"""
Получает список чатов бота.
:param count: Количество чатов (по умолчанию 50)
:param marker: Маркер для пагинации
Args:
count (int): Количество чатов (по умолчанию 50).
marker (Optional[int]): Маркер для пагинации.
:return: Список чатов
Returns:
Chats: Список чатов.
"""
return await GetChats(
@@ -398,9 +460,11 @@ class Bot(BaseConnection):
"""
Получает чат по ссылке.
:param link: Ссылка на чат
Args:
link (str): Ссылка на чат.
:return: Объект чата
Returns:
Chat: Объект чата.
"""
return await GetChatByLink(bot=self, link=link).fetch()
@@ -413,9 +477,11 @@ class Bot(BaseConnection):
"""
Получает чат по ID.
:param id: ID чата
Args:
id (int): ID чата.
:return: Объект чата
Returns:
Chat: Объект чата.
"""
return await GetChatById(bot=self, id=id).fetch()
@@ -432,13 +498,15 @@ class Bot(BaseConnection):
"""
Редактирует параметры чата.
:param chat_id: ID чата
:param icon: Данные иконки чата
:param title: Новый заголовок чата
:param pin: ID сообщения для закрепления
:param notify: Отправлять уведомление получателю (по умолчанию берется значение из бота)
Args:
chat_id (int): ID чата.
icon (Optional[PhotoAttachmentRequestPayload]): Иконка.
title (Optional[str]): Новый заголовок.
pin (Optional[str]): ID сообщения для закрепления.
notify (Optional[bool]): Флаг уведомления.
:return: Обновленный объект чата
Returns:
Chat: Обновленный объект чата.
"""
return await EditChat(
@@ -458,9 +526,11 @@ class Bot(BaseConnection):
"""
Получает видео по токену.
:param video_token: Токен видео
Args:
video_token (str): Токен видео.
:return: Объект видео
Returns:
Video: Объект видео.
"""
return await GetVideo(
@@ -478,11 +548,13 @@ class Bot(BaseConnection):
"""
Отправляет callback ответ.
:param callback_id: ID callback
:param message: Сообщение для отправки
:param notification: Текст уведомления
Args:
callback_id (str): ID callback.
message (Optional[Message]): Сообщение для отправки.
notification (Optional[str]): Текст уведомления.
:return: Результат отправки callback
Returns:
SendedCallback: Результат отправки callback.
"""
return await SendCallback(
@@ -502,11 +574,13 @@ class Bot(BaseConnection):
"""
Закрепляет сообщение в чате.
:param chat_id: ID чата
:param message_id: ID сообщения
:param notify: Отправлять уведомление получателю (по умолчанию берется значение из бота)
Args:
chat_id (int): ID чата.
message_id (str): ID сообщения.
notify (Optional[bool]): Флаг уведомления.
:return: Закрепленное сообщение
Returns:
PinnedMessage: Закрепленное сообщение.
"""
return await PinMessage(
@@ -524,9 +598,11 @@ class Bot(BaseConnection):
"""
Удаляет закрепленное сообщение в чате.
:param chat_id: ID чата
Args:
chat_id (int): ID чата.
:return: Результат удаления
Returns:
DeletedPinMessage: Результат удаления.
"""
return await DeletePinMessage(
@@ -540,11 +616,13 @@ class Bot(BaseConnection):
) -> ChatMember:
"""
Получает информацию о боте в конкретном чате.
Получает информацию о боте в чате.
:param chat_id: ID чата
Args:
chat_id (int): ID чата.
:return: Информация о боте в чате
Returns:
ChatMember: Информация о боте в чате.
"""
return await GetMeFromChat(
@@ -560,9 +638,11 @@ class Bot(BaseConnection):
"""
Удаляет бота из чата.
:param chat_id: ID чата
Args:
chat_id (int): ID чата.
:return: Результат удаления
Returns:
DeletedBotFromChat: Результат удаления.
"""
return await DeleteMeFromMessage(
@@ -578,9 +658,11 @@ class Bot(BaseConnection):
"""
Получает список администраторов чата.
:param chat_id: ID чата
Args:
chat_id (int): ID чата.
:return: Список администраторов
Returns:
GettedListAdminChat: Список администраторов.
"""
return await GetListAdminChat(
@@ -598,11 +680,13 @@ class Bot(BaseConnection):
"""
Добавляет администраторов в чат.
:param chat_id: ID чата
:param admins: Список администраторов
:param marker: Маркер для пагинации
Args:
chat_id (int): ID чата.
admins (List[ChatAdmin]): Список администраторов.
marker (Optional[int]): Маркер для пагинации.
:return: Результат добавления
Returns:
AddedListAdminChat: Результат добавления.
"""
return await AddAdminChat(
@@ -621,10 +705,12 @@ class Bot(BaseConnection):
"""
Удаляет администратора из чата.
:param chat_id: ID чата
:param user_id: ID пользователя
Args:
chat_id (int): ID чата.
user_id (int): ID пользователя.
:return: Результат удаления
Returns:
RemovedAdmin: Результат удаления.
"""
return await RemoveAdmin(
@@ -644,12 +730,14 @@ class Bot(BaseConnection):
"""
Получает участников чата.
:param chat_id: ID чата
:param user_ids: Список ID участников
:param marker: Маркер для пагинации
:param count: Количество участников
Args:
chat_id (int): ID чата.
user_ids (Optional[List[int]]): Список ID участников.
marker (Optional[int]): Маркер для пагинации.
count (Optional[int]): Количество участников.
:return: Список участников
Returns:
GettedMembersChat: Список участников.
"""
return await GetMembersChat(
@@ -669,10 +757,12 @@ class Bot(BaseConnection):
"""
Получает участника чата.
:param chat_id: ID чата
:param user_id: ID участника
Args:
chat_id (int): ID чата.
user_id (int): ID участника.
:return: Участник
Returns:
Optional[ChatMember]: Участник.
"""
members = await self.get_chat_members(
@@ -694,10 +784,12 @@ class Bot(BaseConnection):
"""
Добавляет участников в чат.
:param chat_id: ID чата
:param user_ids: Список ID пользователей
Args:
chat_id (int): ID чата.
user_ids (List[int]): Список ID пользователей.
:return: Результат добавления
Returns:
AddedMembersChat: Результат добавления.
"""
return await AddMembersChat(
@@ -716,11 +808,13 @@ class Bot(BaseConnection):
"""
Исключает участника из чата.
:param chat_id: ID чата
:param user_id: ID пользователя
:param block: Блокировать пользователя (по умолчанию False)
Args:
chat_id (int): ID чата.
user_id (int): ID пользователя.
block (bool): Блокировать пользователя (по умолчанию False).
:return: Результат исключения
Returns:
RemovedMemberChat: Результат исключения.
"""
return await RemoveMemberChat(
@@ -737,7 +831,8 @@ class Bot(BaseConnection):
"""
Получает обновления для бота.
:return: Список обновлений
Returns:
Dict: Список обновлений.
"""
return await GetUpdates(
@@ -752,9 +847,11 @@ class Bot(BaseConnection):
"""
Получает URL для загрузки файлов.
:param type: Тип загружаемого файла
Args:
type (UploadType): Тип загружаемого файла.
:return: URL для загрузки
Returns:
GettedUploadUrl: URL для загрузки.
"""
return await GetUploadURL(
@@ -770,9 +867,11 @@ class Bot(BaseConnection):
"""
Устанавливает список команд бота.
:param commands: Список команд
Args:
*commands (BotCommand): Список команд.
:return: Обновленная информация о боте
Returns:
User: Обновленная информация о боте.
"""
return await ChangeInfo(
@@ -785,7 +884,8 @@ class Bot(BaseConnection):
"""
Получает список всех подписок.
:return: Объект со списком подписок
Returns:
GettedSubscriptions: Объект со списком подписок.
"""
return await GetSubscriptions(bot=self).fetch()
@@ -799,17 +899,14 @@ class Bot(BaseConnection):
"""
Подписывает бота на получение обновлений через WebHook.
После вызова этого метода бот будет получать уведомления о новых событиях в чатах на указанный URL.
Ваш сервер должен прослушивать один из следующих портов: `80`, `8080`, `443`, `8443`, `16384`-`32383`.
:param url: URL HTTP(S)-эндпойнта вашего бота. Должен начинаться с http(s)://
:param update_types: Список типов обновлений, которые ваш бот хочет получать.
Для полного списка типов см. объект
:param secret: От 5 до 256 символов. Cекрет, который должен быть отправлен в заголовке X-Max-Bot-Api-Secret
в каждом запросе Webhook. Разрешены только символы A-Z, a-z, 0-9, и дефис.
Заголовок рекомендован, чтобы запрос поступал из установленного веб-узла
Args:
url (str): URL HTTP(S)-эндпойнта вашего бота.
update_types (Optional[List[UpdateType]]): Список типов обновлений.
secret (Optional[str]): Секрет для Webhook.
:return: Обновленная информация о боте
Returns:
Subscribed: Результат подписки.
"""
return await SubscribeWebhook(
@@ -826,12 +923,12 @@ class Bot(BaseConnection):
"""
Отписывает бота от получения обновлений через WebHook.
После вызова этого метода бот перестает получать уведомления о новых событиях,
и доступна доставка уведомлений через API с длительным опросом.
:param url: URL HTTP(S)-эндпойнта вашего бота. Должен начинаться с http(s)://
Args:
url (str): URL HTTP(S)-эндпойнта вашего бота.
:return: Обновленная информация о боте
Returns:
Unsubscribed: Результат отписки.
"""
return await UnsubscribeWebhook(
@@ -842,7 +939,10 @@ class Bot(BaseConnection):
async def delete_webhook(self):
"""
Удаление всех подписок на Webhook
Удаляет все подписки на Webhook.
Returns:
None
"""
subs = await self.get_subscriptions()
@@ -852,28 +952,3 @@ class Bot(BaseConnection):
await self.unsubscribe_webhook(sub.url)
logger_bot.info('Удалена подписка на Webhook: %s', sub.url)
# async def download_file(
# self,
# path: str,
# url: str,
# token: str
# ):
# """
# Скачивает медиа с указанной ссылки по токену, сохраняя по определенному пути
# :param path: Путь сохранения медиа
# :param url: Ссылка на медиа
# :param token: Токен медиа
# :return: Числовой статус
# """
# return await DownloadMedia(
# bot=self,
# path=path,
# media_url=url,
# media_token=token
# ).fetch()

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

View File

@@ -30,9 +30,7 @@ class BaseConnection:
"""
Базовый класс для всех методов API.
Содержит общую логику выполнения запроса (например, сериализацию, отправку HTTP-запроса, обработку ответа).
Метод request() может быть переопределён в потомках при необходимости.
Содержит общую логику выполнения запроса (сериализация, отправка HTTP-запроса, обработка ответа).
"""
API_URL = 'https://botapi.max.ru'
@@ -41,6 +39,16 @@ 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
@@ -55,18 +63,22 @@ class BaseConnection:
):
"""
Выполняет 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 задан)
Returns:
model | dict | Error: Объект модели, dict или ошибка.
- dict (если is_return_raw=True)
Raises:
RuntimeError: Если бот не инициализирован.
MaxConnection: Ошибка соединения.
InvalidToken: Ошибка авторизации (401).
"""
if self.bot is None:
@@ -121,14 +133,17 @@ class BaseConnection:
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:
@@ -160,14 +175,18 @@ 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:
@@ -198,32 +217,3 @@ class BaseConnection:
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

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

@@ -3,11 +3,14 @@ from __future__ import annotations
import asyncio
import functools
from typing import Any, Awaitable, Callable, Dict, List, TYPE_CHECKING, Optional
from typing import Any, Awaitable, Callable, Dict, List, TYPE_CHECKING, Literal, Optional
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,8 +63,8 @@ class Dispatcher:
"""
Инициализация диспетчера.
:param router_id: Идентификатор роутера, используется при логгировании.
По умолчанию индекс зарегистированного роутера в списке
Args:
router_id (str | None): Идентификатор роутера для логов.
"""
self.router_id = router_id
@@ -70,6 +73,7 @@ class Dispatcher:
self.contexts: List[MemoryContext] = []
self.routers: List[Router | Dispatcher] = []
self.filters: List[MagicFilter] = []
self.base_filters: List[BaseFilter] = []
self.middlewares: List[BaseMiddleware] = []
self.bot: Optional[Bot] = None
@@ -85,6 +89,7 @@ class Dispatcher:
self.dialog_cleared = Event(update_type=UpdateType.DIALOG_CLEARED, router=self)
self.dialog_muted = Event(update_type=UpdateType.DIALOG_MUTED, router=self)
self.dialog_unmuted = Event(update_type=UpdateType.DIALOG_UNMUTED, router=self)
self.dialog_removed = Event(update_type=UpdateType.DIALOG_REMOVED, router=self)
self.chat_title_changed = Event(update_type=UpdateType.CHAT_TITLE_CHANGED, router=self)
self.message_callback = Event(update_type=UpdateType.MESSAGE_CALLBACK, router=self)
self.message_chat_created = Event(update_type=UpdateType.MESSAGE_CHAT_CREATED, router=self)
@@ -129,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)
@@ -139,17 +155,52 @@ class Dispatcher:
"""
Добавляет указанные роутеры в диспетчер.
:param routers: Роутеры для добавления.
Args:
*routers (Router): Роутеры для добавления.
"""
self.routers += [r for r in routers]
def outer_middleware(self, middleware: BaseMiddleware) -> None:
"""
Добавляет Middleware на первое место в списке.
Args:
middleware (BaseMiddleware): Middleware.
"""
self.middlewares.insert(0, middleware)
def middleware(self, middleware: BaseMiddleware) -> None:
"""
Добавляет Middleware в конец списка.
Args:
middleware (BaseMiddleware): Middleware.
"""
self.middlewares.append(middleware)
def filter(self, base_filter: BaseFilter) -> None:
"""
Добавляет фильтр в список.
Args:
base_filter (BaseFilter): Фильтр.
"""
self.base_filters.append(base_filter)
async def __ready(self, bot: Bot):
"""
Подготавливает диспетчер: сохраняет бота, регистрирует обработчики, вызывает on_started.
:param bot: Экземпляр бота.
Args:
bot (Bot): Экземпляр бота.
"""
self.bot = bot
@@ -175,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:
@@ -190,24 +244,70 @@ 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()
kwargs_filtered = {k: v for k, v in data.items() if k in func_args}
if kwargs_filtered:
await handler.func_event(event_object, **kwargs_filtered)
else:
await handler.func_event(event_object)
async def process_base_filters(
self,
event: UpdateUnion,
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:
result = await _filter(event)
if isinstance(result, dict):
data.update(result)
elif not result:
return result
return data
async def handle(self, event_object: UpdateUnion):
"""
Основной обработчик события. Применяет фильтры, middleware и вызывает подходящий handler.
Основной обработчик события. Применяет фильтры, middleware и вызывает нужный handler.
:param event_object: Событие, пришедшее в бот.
Args:
event_object (UpdateUnion): Событие.
"""
try:
@@ -232,6 +332,17 @@ class Dispatcher:
if not filter_attrs(event_object, *router.filters):
continue
result_router_filter = await self.process_base_filters(
event=event_object,
filters=router.base_filters
)
if isinstance(result_router_filter, dict):
kwargs.update(result_router_filter)
elif not result_router_filter:
continue
for handler in router.event_handlers:
if not handler.update_type == event_object.update_type:
@@ -247,6 +358,18 @@ class Dispatcher:
func_args = handler.func_event.__annotations__.keys()
if handler.base_filters:
result_filter = await self.process_base_filters(
event=event_object,
filters=handler.base_filters
)
if isinstance(result_filter, dict):
kwargs.update(result_filter)
elif not result_filter:
continue
if isinstance(router, Router):
full_middlewares = self.middlewares + router.middlewares + handler.middlewares
elif isinstance(router, Dispatcher):
@@ -259,25 +382,35 @@ class Dispatcher:
kwargs_filtered = {k: v for k, v in kwargs.items() if k in func_args}
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
@@ -322,9 +455,10 @@ class Dispatcher:
"""
Запускает FastAPI-приложение для приёма обновлений через вебхук.
:param bot: Экземпляр бота.
:param host: Хост, на котором запускается сервер.
:param port: Порт сервера.
Args:
bot (Bot): Экземпляр бота.
host (str): Хост сервера.
port (int): Порт сервера.
"""
if not FASTAPI_INSTALLED:
@@ -345,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()
@@ -380,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!'
@@ -427,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)
@@ -447,8 +558,9 @@ class Event:
"""
Инициализирует событие-декоратор.
:param update_type: Тип события (UpdateType).
:param router: Роутер или диспетчер, в который регистрируется обработчик.
Args:
update_type (UpdateType): Тип события.
router (Dispatcher | Router): Экземпляр роутера или диспетчера.
"""
self.update_type = update_type
@@ -459,7 +571,8 @@ class Event:
"""
Регистрирует функцию как обработчик события.
:return: Исходная функция.
Returns:
Callable: Исходная функция.
"""
def decorator(func_event: Callable):

View File

@@ -24,6 +24,7 @@ class UpdateType(str, Enum):
DIALOG_CLEARED = 'dialog_cleared'
DIALOG_MUTED = 'dialog_muted'
DIALOG_UNMUTED = 'dialog_unmuted'
DIALOG_REMOVED = 'dialog_removed'
# Для начинки диспатчера
ON_STARTED = 'on_started'

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

@@ -1,16 +1,26 @@
from magic_filter import MagicFilter
from .filter import BaseFilter
F = MagicFilter()
__all__ = [
'BaseFilter'
]
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:

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

116
maxapi/filters/command.py Normal file
View File

@@ -0,0 +1,116 @@
from typing import List, Tuple
from ..types.updates import UpdateUnion
from ..filters.filter import BaseFilter
from ..types.updates.message_created import MessageCreated
class Command(BaseFilter):
"""
Фильтр сообщений на соответствие команде.
Args:
commands (str | List[str]): Ожидаемая команда или список команд без префикса.
prefix (str, optional): Префикс команды (по умолчанию '/').
check_case (bool, optional): Учитывать регистр при сравнении (по умолчанию False).
Attributes:
commands (List[str]): Список команд без префикса.
prefix (str): Префикс команды.
check_case (bool): Флаг чувствительности к регистру.
"""
def __init__(self, commands: str | List[str], prefix: str = '/', check_case: bool = False):
"""
Инициализация фильтра команд.
"""
if isinstance(commands, str):
self.commands = [commands]
else:
self.commands = commands
self.prefix = prefix
self.check_case = check_case
if not check_case:
self.commands = [cmd.lower() for cmd in self.commands]
def parse_command(self, text: str) -> Tuple[str, List[str]]:
"""
Извлекает команду из текста.
Args:
text (str): Текст сообщения.
Returns:
Optional[str]: Найденная команда с префиксом, либо None.
"""
args = text.split()
first = args[0]
if not first.startswith(self.prefix):
return '', []
return first[len(self.prefix):], args
async def __call__(self, event: UpdateUnion):
"""
Проверяет, соответствует ли сообщение заданной(ым) команде(ам).
Args:
event (MessageCreated): Событие сообщения.
Returns:
bool: True, если команда совпадает, иначе False.
"""
if not isinstance(event, MessageCreated):
return False
text = event.message.body.text
if not text:
return False
parsed_command, args = self.parse_command(text)
if not parsed_command:
return False
if not self.check_case:
if parsed_command.lower() in [commands.lower() for commands in self.commands]:
return {'args': args}
else:
return False
if parsed_command in self.commands:
return {'args': args}
return False
class CommandStart(Command):
"""
Фильтр для команды /start.
Args:
prefix (str, optional): Префикс команды (по умолчанию '/').
check_case (bool, optional): Учитывать регистр (по умолчанию False).
"""
def __init__(self, prefix = '/', check_case = False):
super().__init__(
'start',
prefix,
check_case
)
async def __call__(self, event):
return await super().__call__(event)

21
maxapi/filters/filter.py Normal file
View File

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

View File

@@ -1,11 +1,10 @@
from typing import Callable, List, Optional
from magic_filter import F, MagicFilter
from magic_filter import MagicFilter
from ..filters.filter import BaseFilter
from ..filters.middleware import BaseMiddleware
from ..types.command import Command, CommandStart
from ..context.state_machine import State
from ..enums.update import UpdateType
@@ -18,7 +17,7 @@ class Handler:
"""
Обработчик события.
Позволяет связать функцию-обработчик с типом обновления, состоянием и набором фильтров.
Связывает функцию-обработчик с типом события, состояниями и фильтрами.
"""
def __init__(
@@ -30,20 +29,18 @@ class Handler:
):
"""
Инициализация обработчика.
Создаёт обработчик события.
: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
self.update_type: UpdateType = update_type
self.filters = []
self.filters: Optional[List[MagicFilter]] = []
self.base_filters: Optional[List[BaseFilter]] = []
self.states: Optional[List[State]] = []
self.middlewares: List[BaseMiddleware] = []
@@ -52,10 +49,11 @@ class Handler:
self.filters.append(arg)
elif isinstance(arg, State):
self.states.append(arg)
elif isinstance(arg, (Command, CommandStart)):
self.filters.insert(0, F.message.body.text.split()[0] == arg.command)
elif isinstance(arg, BaseMiddleware):
self.middlewares.append(arg)
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:
"""
Вызывает хендлер с переданным событием и данными.
Args:
handler (Callable): Хендлер события.
event_object (Any): Событие.
data (dict): Дополнительные данные.
Returns:
Any: Результат работы хендлера.
"""
return await handler(event_object, data)

View File

@@ -0,0 +1,49 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional, Union
from ..types.message import Message
from ..enums.http_method import HTTPMethod
from ..enums.api_path import ApiPath
from ..connection.base import BaseConnection
if TYPE_CHECKING:
from ..bot import Bot
class GetMessage(BaseConnection):
"""
Класс для получения сообщения.
Args:
bot (Bot): Экземпляр бота для выполнения запроса.
message_id (str, optional): ID сообщения (mid), чтобы получить одно сообщение в чате.
"""
def __init__(
self,
bot: 'Bot',
message_id: Optional[str] = None,
):
self.bot = bot
self.message_id = message_id
async def fetch(self) -> Message:
"""
Выполняет GET-запрос для получения сообщения.
Returns:
Message: Объект с полученным сообщением.
"""
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request(
method=HTTPMethod.GET,
path=ApiPath.MESSAGES + '/' + self.message_id,
model=Message,
params=self.bot.params
)

View File

@@ -18,6 +18,7 @@ from ...types.updates.user_removed import UserRemoved
from ...types.updates.dialog_cleared import DialogCleared
from ...types.updates.dialog_muted import DialogMuted
from ...types.updates.dialog_unmuted import DialogUnmuted
from ...types.updates.dialog_removed import DialogRemoved
if TYPE_CHECKING:
from ...bot import Bot
@@ -38,7 +39,8 @@ UPDATE_MODEL_MAPPING = {
UpdateType.BOT_STOPPED: BotStopped,
UpdateType.DIALOG_CLEARED: DialogCleared,
UpdateType.DIALOG_MUTED: DialogMuted,
UpdateType.DIALOG_UNMUTED: DialogUnmuted
UpdateType.DIALOG_UNMUTED: DialogUnmuted,
UpdateType.DIALOG_REMOVED: DialogRemoved
}

View File

@@ -29,14 +29,16 @@ from ..types.attachments.buttons.open_app_button import OpenAppButton
from ..types.attachments.buttons.request_geo_location_button import RequestGeoLocationButton
from ..types.attachments.buttons.message_button import MessageButton
from ..types.attachments.image import PhotoAttachmentRequestPayload
from ..types.message import Message
from ..types.message import Message, NewMessageLink
from ..types.command import Command, BotCommand, CommandStart
from ..filters.command import Command, CommandStart
from ..types.command import BotCommand
from .input_media import InputMedia
from .input_media import InputMediaBuffer
__all__ = [
'NewMessageLink',
'PhotoAttachmentRequestPayload',
'DialogUnmuted',
'DialogMuted',

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

@@ -114,29 +114,3 @@ class Attachment(BaseModel):
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,
# )

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Literal, Optional
from ...enums.attachment import AttachmentType
@@ -11,9 +11,8 @@ class Audio(Attachment):
Вложение с типом аудио.
Attributes:
type (Literal['audio']): Тип вложения, всегда 'audio'.
transcription (Optional[str]): Транскрипция аудио (если есть).
"""
type: AttachmentType = AttachmentType.AUDIO
type: Literal[AttachmentType.AUDIO]
transcription: Optional[str] = None

View File

@@ -1,10 +1,11 @@
from typing import Literal
from pydantic import BaseModel
from ..attachment import ButtonsPayload
from ....enums.attachment import AttachmentType
from ..attachment import Attachment
class AttachmentButton(BaseModel):
class AttachmentButton(Attachment):
"""
Модель кнопки вложения для сообщения.
@@ -14,5 +15,4 @@ class AttachmentButton(BaseModel):
payload: Полезная нагрузка кнопки (массив рядов кнопок)
"""
type: Literal['inline_keyboard'] = 'inline_keyboard'
payload: ButtonsPayload
type: Literal[AttachmentType.INLINE_KEYBOARD]

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Literal, Optional
from ...enums.attachment import AttachmentType
@@ -11,11 +11,10 @@ class File(Attachment):
Вложение с типом файла.
Attributes:
type (Literal['file']): Тип вложения, всегда 'file'.
filename (Optional[str]): Имя файла.
size (Optional[int]): Размер файла в байтах.
"""
type: AttachmentType = AttachmentType.FILE
type: Literal[AttachmentType.FILE]
filename: Optional[str] = None
size: Optional[int] = None

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Literal, Optional
from pydantic import BaseModel
@@ -31,4 +31,4 @@ class Image(Attachment):
type (Literal['image']): Тип вложения, всегда 'image'.
"""
type: AttachmentType = AttachmentType.IMAGE
type: Literal[AttachmentType.IMAGE]

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Literal, Optional
from ...enums.attachment import AttachmentType
@@ -11,11 +11,10 @@ class Location(Attachment):
Вложение с типом геолокации.
Attributes:
type (Literal['location']): Тип вложения, всегда 'location'.
latitude (Optional[float]): Широта.
longitude (Optional[float]): Долгота.
"""
type: AttachmentType = AttachmentType.LOCATION
type: Literal[AttachmentType.LOCATION]
latitude: Optional[float] = None
longitude: Optional[float] = None

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Literal, Optional
from ...enums.attachment import AttachmentType
@@ -11,13 +11,12 @@ class Share(Attachment):
Вложение с типом "share" (поделиться).
Attributes:
type (Literal['share']): Тип вложения, всегда 'share'.
title (Optional[str]): Заголовок для шаринга.
description (Optional[str]): Описание.
image_url (Optional[str]): URL изображения для предпросмотра.
"""
type: AttachmentType = AttachmentType.SHARE
type: Literal[AttachmentType.SHARE]
title: Optional[str] = None
description: Optional[str] = None
image_url: Optional[str] = None

View File

@@ -1,4 +1,4 @@
from typing import Optional
from typing import Literal, Optional
from ...enums.attachment import AttachmentType
@@ -11,11 +11,10 @@ class Sticker(Attachment):
Вложение с типом стикера.
Attributes:
type (Literal['sticker']): Тип вложения, всегда 'sticker'.
width (Optional[int]): Ширина стикера в пикселях.
height (Optional[int]): Высота стикера в пикселях.
"""
type: AttachmentType = AttachmentType.STICKER
type: Literal[AttachmentType.STICKER]
width: Optional[int] = None
height: Optional[int] = None

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, Literal, Optional
from pydantic import BaseModel, Field
from ...enums.attachment import AttachmentType
@@ -51,7 +51,6 @@ class Video(Attachment):
Вложение с типом видео.
Attributes:
type (Optional[Literal['video']]): Тип вложения, всегда 'video'.
token (Optional[str]): Токен видео.
urls (Optional[VideoUrl]): URLs видео разных разрешений.
thumbnail (VideoThumbnail): Миниатюра видео.
@@ -61,7 +60,7 @@ class Video(Attachment):
bot (Optional[Any]): Ссылка на экземпляр бота, не сериализуется.
"""
type: AttachmentType = AttachmentType.VIDEO
type: Literal[AttachmentType.VIDEO]
token: Optional[str] = None
urls: Optional[VideoUrl] = None
thumbnail: VideoThumbnail

View File

@@ -2,33 +2,6 @@ from typing import Optional
from pydantic import BaseModel
class Command:
"""
Класс для представления команды бота.
Attributes:
text (str): Текст команды без префикса.
prefix (str): Префикс команды. По умолчанию '/'.
"""
def __init__(self, text: str, prefix: str = '/'):
self.text = text
self.prefix = prefix
@property
def command(self):
"""
Возвращает полную команду с префиксом.
Returns:
str: Команда, состоящая из префикса и текста.
"""
return self.prefix + self.text
class BotCommand(BaseModel):
"""
@@ -41,18 +14,3 @@ class BotCommand(BaseModel):
name: str
description: Optional[str] = None
class CommandStart(Command):
"""
Класс для представления команды /start бота.
Attributes:
prefix (str): Префикс команды. По умолчанию '/'.
"""
text = 'start'
def __init__(self, prefix: str = '/'):
self.prefix = prefix

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

@@ -3,20 +3,14 @@ from __future__ import annotations
from pydantic import BaseModel, Field
from typing import Any, Optional, List, Union, TYPE_CHECKING
from ..types.attachments import Attachments
from ..enums.text_style import TextStyle
from ..enums.parse_mode import ParseMode
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
@@ -91,18 +85,7 @@ class MessageBody(BaseModel):
seq: int
text: Optional[str] = None
attachments: Optional[
List[
Union[
AttachmentButton,
Audio,
Video,
File,
Image,
Sticker,
Share,
Location
]
]
List[Attachments]
] = Field(default_factory=list) # type: ignore
markup: Optional[

View File

@@ -0,0 +1,30 @@
from typing import TYPE_CHECKING, Optional
from .update import Update
from ...types.users import User
if TYPE_CHECKING:
from ...bot import Bot
class DialogRemoved(Update):
"""
Обновление, сигнализирующее об удалении диалога с ботом.
Attributes:
chat_id (int): Идентификатор чата.
user (User): Пользователь (бот).
user_locale (Optional[str]): Локаль пользователя.
"""
chat_id: int
user: User
user_locale: Optional[str] = None
if TYPE_CHECKING:
bot: Optional[Bot]
def get_ids(self):
return (self.chat_id, self.user.user_id)

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

@@ -22,8 +22,19 @@ 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

View File

@@ -1,6 +1,6 @@
[project]
name = "maxapi"
version = "0.9.3"
version = "0.9.5"
description = "Библиотека для разработки чат-ботов с помощью API мессенджера MAX"
readme = "README.md"
requires-python = ">=3.10"
@@ -13,7 +13,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
]
dependencies = [
"aiohttp>=3.8.0",
"aiohttp>=3.12.14",
"magic_filter>=1.0.0",
"pydantic>=1.8.0",
"aiofiles==24.1.0",

View File

@@ -10,6 +10,7 @@
| `dialog_cleared` | Пользователь очистил историю диалога с ботом |
| `dialog_muted` | Пользователь отключил оповещения от чата бота |
| `dialog_unmuted` | Пользователь включил оповещения от чата бота |
| `dialog_removed` | Пользователь удалил диалог с ботом |
| `chat_title_changed` | Изменено название чата |
| `message_callback` | Пользователь нажал на callback-кнопку (inline button) |
| `message_chat_created`| Срабатывает когда пользователь нажал на кнопку с действием "Создать чат" (работает некорректно со стороны API MAX, ждем исправлений) |