Compare commits

..

83 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
1b0b118239 0.9.3 2025-07-30 09:25:08 +03:00
8d62d0d20a Обновлены примеры использования Middleware 2025-07-30 09:25:00 +03:00
a7173b4371 Добавлены доп. настройки в пример запуска 2025-07-30 08:57:58 +03:00
b25b0c0a5e Переделано оформление 2025-07-30 08:57:41 +03:00
af6b1f1a74 Добавлен PhotoAttachmentRequestPayload для легкого импорта 2025-07-29 22:31:23 +03:00
b1f8fb91cb Поправлена аннотация photo в __init__ ChangeInfo 2025-07-29 22:30:57 +03:00
9c0567d858 Поправлена аннотация photo в change_info 2025-07-29 22:30:10 +03:00
9ab960ebe4 Добавлен метод close_session() 2025-07-29 14:17:35 +03:00
a653ed6792 Изменен фильтр на State. Вместо == теперь in. Работает если в хендлере указано несколько фильтров на State 2025-07-28 15:13:52 +03:00
103535d3ba Объект подписки на Webhook 2025-07-28 01:17:40 +03:00
82dc8d255a Объект ответа подписки на Webhook 2025-07-28 01:16:20 +03:00
35b60a1b44 Объект ответа отписки от Webhook 2025-07-28 01:14:41 +03:00
e07aeef726 Объект ответа подписки на Webhook 2025-07-28 01:14:34 +03:00
03a60014d4 Объект ответа полученных подписок Webhook 2025-07-28 01:14:25 +03:00
b69713f996 Класс на отписку от Webhook 2025-07-28 01:14:07 +03:00
9010c697f4 Класс для подписки на Webhook 2025-07-28 01:13:56 +03:00
b994717f64 Класс для получения подписок Webhook 2025-07-28 01:13:46 +03:00
44dcf39f42 Добавлен API путь /subscriptions 2025-07-28 01:13:12 +03:00
0bd59ddb4a Добавлена проверка на подписки Webhook при start_polling 2025-07-28 01:12:55 +03:00
f497760f5b Добавлены методы: get_subscriptions, subscribe_webhook, unsubscribe_webhook, delete_webhook. Добавлен auto_check_subscriptions к Bot. 2025-07-28 01:12:11 +03:00
5c64c3d040 Правки информации по user_removed 2025-07-27 16:59:12 +03:00
c8dd896691 Переработана система Middleware 2025-07-27 16:58:54 +03:00
e0e7bb53b6 0.9.2 Опциональные fastapi uvicorn 2025-07-27 14:20:08 +03:00
adbea8e55f Поправлены докстринги у Dispatcher и Router 2025-07-27 14:19:42 +03:00
3ff9907a58 0.9.2 fastapi, uvicorn опциональны 2025-07-27 02:15:03 +03:00
6f86d15de4 Добавлен filename к InputMediaBuffer 2025-07-27 02:12:58 +03:00
7ea24fe2af Изменен пример запуска 2025-07-27 02:12:30 +03:00
39fb0c5823 Добавлены типы событий: bot_stopped, dialog_cleared, dialog_muted, dialog_unmuted 2025-07-27 02:12:22 +03:00
af84301e4f Добавлены типы событий: bot_stopped, dialog_cleared, dialog_muted, dialog_unmuted. Правки с auto_requests 2025-07-27 02:12:16 +03:00
ec432fe8ce Добавлены типы событий: bot_stopped, dialog_cleared, dialog_muted, dialog_unmuted 2025-07-27 02:11:48 +03:00
1bfd93f2ea chat_title обязателен 2025-07-27 02:11:30 +03:00
7925087ac7 Добавлено в User @property full_name 2025-07-27 02:11:04 +03:00
7ed540683c Добавлены типы событий: bot_stopped, dialog_cleared, dialog_muted, dialog_unmuted 2025-07-27 02:10:34 +03:00
30350c8521 Откат преобразования Recipient, одного из полей может не быть 2025-07-27 02:10:25 +03:00
54683256ce Добавлены типы событий: bot_stopped, dialog_cleared, dialog_muted, dialog_unmuted 2025-07-27 02:09:50 +03:00
be7f98976e Поправлена логика задержки отправки сообщения с InputMedia, InputMediaBuffer 2025-07-27 02:09:34 +03:00
54c073ab76 Добавлены типы событий: bot_stopped, dialog_cleared, dialog_muted, dialog_unmuted 2025-07-27 02:09:04 +03:00
29b319768b Добавлены типы событий: bot_stopped, dialog_cleared, dialog_muted, dialog_unmuted. Изменена система запуска вебхуков 2025-07-27 02:08:45 +03:00
5e98e540ea Изменена система запуска вебхука, fastapi с uvicorn опциональны 2025-07-27 02:08:27 +03:00
1df293f44d Маленькие правки 2025-07-27 02:07:22 +03:00
c667b82a6c Добавлены типы событий: bot_stopped, dialog_cleared, dialog_muted, dialog_unmuted, message_chat_created 2025-07-27 02:07:06 +03:00
62523c1eb2 Правки по преобразованию 2025-07-25 00:54:57 +03:00
b0b7040206 Добавлен аттрибут is_channel 2025-07-25 00:54:35 +03:00
29d3d7c042 Рефактор для получения обновлений 2025-07-25 00:53:35 +03:00
fd048e8544 ignore 2025-07-25 00:53:08 +03:00
32c0ca7647 Правки по ruff + mypy 2025-07-25 00:52:58 +03:00
02b4e2d39a Создан отдельный файл для MemoryContext 2025-07-25 00:52:38 +03:00
354c296fed Правки по ruff + mypy 2025-07-25 00:52:16 +03:00
8f93cf36e4 Удален импорт UpdateUnion 2025-07-25 00:49:20 +03:00
e1064761e4 Поправлены импорты 2025-07-25 00:48:53 +03:00
2420e4232e Добавлена обработка Error 2025-07-24 19:38:08 +03:00
fd9986b02e Доработана загрузка медиа 2025-07-24 19:37:43 +03:00
48b480ff9e Поправлена загрузка медиа 2025-07-24 19:37:30 +03:00
69e6274f42 Перенесены константы из send_message.py и добавлена AFTER_MEDIA_INPUT_DELAY 2025-07-24 19:37:11 +03:00
0daa9d508d Поправлен "спам" общей ошибка таймаута 2025-07-24 19:35:59 +03:00
0ae0758bc1 Добавлен after_input_media_delay в Bot, поправлены типы возвращаемых данных 2025-07-24 19:33:33 +03:00
7d2826c4b5 Правки по mypy 2025-07-24 18:54:41 +03:00
2cd3d64bb8 Небольшая оптимизация 2025-07-24 01:50:25 +03:00
1fb2fbd654 Добавлен файл для будущих исключений MAX 2025-07-24 01:50:06 +03:00
7534ae60a8 Убран лишний импорт 2025-07-24 01:48:03 +03:00
5ad37b8adf Заменен [] на Field(default_factory=list) для списков в pydantic-моделях, чтобы избежать shared mutable default 2025-07-24 01:30:28 +03:00
d731e5e905 0.9.1 2025-07-20 22:56:53 +03:00
114 changed files with 3041 additions and 1411 deletions

106
README.md
View File

@@ -1,23 +1,53 @@
# Асинхронный MAX API <p align="center">
<a href="https://github.com/love-apples/maxapi"><img src="https://s.iimg.su/s/29/DCvw4dx2HZgFdTcpqGAs6xdnJnvD44r9zLga2GGe.png" alt="MaxAPI"></a>
</p>
[![PyPI version](https://img.shields.io/pypi/v/maxapi.svg)](https://pypi.org/project/maxapi/)
[![Python Version](https://img.shields.io/pypi/pyversions/maxapi.svg)](https://pypi.org/project/maxapi/)
[![License](https://img.shields.io/github/license/love-apples/maxapi.svg)](https://love-apples/maxapi/blob/main/LICENSE)
--- <p align="center">
<a href='https://github.com/love-apples/maxapi/wiki'>Документация</a>
<a href='https://github.com/love-apples/maxapi/tree/main/examples'>Примеры</a>
<a href='https://max.ru/join/IPAok63C3vFqbWTFdutMUtjmrAkGqO56YeAN7iyDfc8'>MAX Чат</a>
<a href='https://t.me/maxapi_github'>TG Чат</a>
</p>
## 📦 Установка <p align="center">
<a href='https://pypi.org/project/maxapi/'>
<img src='https://img.shields.io/pypi/v/maxapi.svg' alt='PyPI version'>
</a>
<a href='https://pypi.org/project/maxapi/'>
<img src='https://img.shields.io/pypi/pyversions/maxapi.svg' alt='Python Version'>
</a>
<a href='https://love-apples/maxapi/blob/main/LICENSE'>
<img src='https://img.shields.io/github/license/love-apples/maxapi.svg' alt='License'>
</a>
</p>
## 📦 Установка из PyPi
Стабильная версия
```bash ```bash
pip install maxapi pip install maxapi
``` ```
--- ## 🐱‍👤 Установка из GitHub
Свежая версия, возможны баги. Рекомендуется только для ознакомления с новыми коммитами.
```bash
pip install git+https://github.com/love-apples/maxapi.git
```
## 🚀 Быстрый старт ## 🚀 Быстрый старт
Если вы тестируете бота в чате - не забудьте дать ему права администратора! Если вы тестируете бота в чате - не забудьте дать ему права администратора!
### Запуск Polling
Если у бота установлены подписки на Webhook - события не будут приходить при методе `start_polling`. При таком случае удалите подписки на Webhook через [MasterBot](https://web.max.ru/masterbot) или через `await bot.delete_webhook()` перед `start_polling`.
```python ```python
import asyncio import asyncio
import logging import logging
@@ -30,7 +60,7 @@ logging.basicConfig(level=logging.INFO)
bot = Bot(ут_ваш_токен') bot = Bot(ут_ваш_токен')
dp = Dispatcher() dp = Dispatcher()
# Ответ бота при нажатии на кнопку "Начать"
@dp.bot_started() @dp.bot_started()
async def bot_started(event: BotStarted): async def bot_started(event: BotStarted):
await event.bot.send_message( await event.bot.send_message(
@@ -38,7 +68,7 @@ async def bot_started(event: BotStarted):
text='Привет! Отправь мне /start' text='Привет! Отправь мне /start'
) )
# Ответ бота на команду /start
@dp.message_created(Command('start')) @dp.message_created(Command('start'))
async def hello(event: MessageCreated): async def hello(event: MessageCreated):
await event.message.answer(f"Пример чат-бота для MAX 💙") await event.message.answer(f"Пример чат-бота для MAX 💙")
@@ -52,44 +82,42 @@ if __name__ == '__main__':
asyncio.run(main()) asyncio.run(main())
``` ```
--- ### Запуск Webhook
## 📚 Документация Перед запуском бота через Webhook, вам нужно установить дополнительные зависимости (fastapi, uvicorn). Можно это сделать через команду:
```bash
pip install maxapi[webhook]
```
[Тут](https://github.com/love-apples/maxapi/wiki) Указан пример простого запуска, для более низкого уровня можете рассмотреть [этот пример](https://github.com/love-apples/maxapi/blob/main/examples/webhook/low_level.py).
```python
import asyncio
import logging
--- from maxapi import Bot, Dispatcher
from maxapi.types import BotStarted, Command, MessageCreated
## ⭐️ Примеры logging.basicConfig(level=logging.INFO)
[Тут](https://github.com/love-apples/maxapi/tree/main/examples) bot = Bot(ут_ваш_токен')
dp = Dispatcher()
---
## 🧩 Возможности # Команда /start боту
@dp.message_created(Command('start'))
- ✅ Middleware async def hello(event: MessageCreated):
- ✅ Роутеры await event.message.answer(f"Привет из вебхука!")
- ✅ Билдер инлайн клавиатур
- ✅ Простая загрузка медиафайлов
- ✅ MagicFilter
- ✅ Внутренние функции моделей
- ✅ Контекстный менеджер
- ✅ Поллинг
- ✅ Вебхук
- ✅ Логгирование
---
## 💬 Обратная связь и поддержка async def main():
await dp.handle_webhook(
bot=bot,
host='localhost',
port=8080,
log_level='critical' # Можно убрать для подробного логгирования
)
- MAX: [Чат](https://max.ru/join/IPAok63C3vFqbWTFdutMUtjmrAkGqO56YeAN7iyDfc8)
- Telegram: [@loveappless](https://t.me/loveappless)
- Telegram чат: [MAXApi | Обсуждение](https://t.me/maxapi_github)
---
## 📄 Лицензия if __name__ == '__main__':
asyncio.run(main())
Этот проект распространяется под лицензией MIT. См. файл [LICENSE](https://github.com/love-apples/maxapi/blob/main/LICENSE) для подробностей. ```

View File

@@ -1,11 +1,13 @@
## ⭐️ Примеры ## ⭐️ Примеры
- [Эхо бот](https://github.com/love-apples/maxapi/blob/main/examples/echo/main.py) - [Эхо бот](https://github.com/love-apples/maxapi/blob/main/examples/echo/main.py)
- [Обработчик доступных событий](https://github.com/love-apples/maxapi/blob/main/examples/events/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) - [Обработчики с 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) - [Демонстрация роутинга, 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) - [Получение 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/middleware_in_handlers/main.py)
- [Вебхуки](https://github.com/love-apples/maxapi/tree/main/examples/webhook) - [Вебхуки](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/keyboard/main.py)
- [Миддлварь в роутерах](https://github.com/love-apples/maxapi/tree/main/examples/middleware_for_router/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

@@ -13,7 +13,13 @@ from maxapi.types import (
MessageEdited, MessageEdited,
MessageRemoved, MessageRemoved,
UserAdded, UserAdded,
UserRemoved UserRemoved,
BotStopped,
DialogCleared,
DialogMuted,
DialogUnmuted,
ChatButton,
MessageChatCreated
) )
from maxapi.utils.inline_keyboard import InlineKeyboardBuilder from maxapi.utils.inline_keyboard import InlineKeyboardBuilder
@@ -38,9 +44,9 @@ async def hello(event: MessageCreated):
) )
) )
builder.add( builder.add(
CallbackButton( ChatButton(
text='Кнопка 3', text='Создать чат',
payload='btn_3', chat_title='Тест чат'
) )
) )
@@ -49,12 +55,17 @@ async def hello(event: MessageCreated):
attachments=[ attachments=[
builder.as_markup(), builder.as_markup(),
] # Для MAX клавиатура это вложение, ] # Для MAX клавиатура это вложение,
) # поэтому она в списке вложений ) # поэтому она в attachments
@dp.bot_added() @dp.bot_added()
async def bot_added(event: BotAdded): 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, chat_id=event.chat.id,
text=f'Привет чат {event.chat.title}!' text=f'Привет чат {event.chat.title}!'
) )
@@ -62,7 +73,7 @@ async def bot_added(event: BotAdded):
@dp.message_removed() @dp.message_removed()
async def message_removed(event: MessageRemoved): async def message_removed(event: MessageRemoved):
await event.bot.send_message( await bot.send_message(
chat_id=event.chat_id, chat_id=event.chat_id,
text='Я всё видел!' text='Я всё видел!'
) )
@@ -70,7 +81,7 @@ async def message_removed(event: MessageRemoved):
@dp.bot_started() @dp.bot_started()
async def bot_started(event: BotStarted): async def bot_started(event: BotStarted):
await event.bot.send_message( await bot.send_message(
chat_id=event.chat_id, chat_id=event.chat_id,
text='Привет! Отправь мне /start' text='Привет! Отправь мне /start'
) )
@@ -78,9 +89,9 @@ async def bot_started(event: BotStarted):
@dp.chat_title_changed() @dp.chat_title_changed()
async def chat_title_changed(event: ChatTitleChanged): async def chat_title_changed(event: ChatTitleChanged):
await event.bot.send_message( await bot.send_message(
chat_id=event.chat_id, chat_id=event.chat_id,
text=f'Крутое новое название "{event.chat.title}!"' text=f'Крутое новое название "{event.title}"!'
) )
@@ -100,7 +111,14 @@ async def message_edited(event: MessageEdited):
@dp.user_removed() @dp.user_removed()
async def user_removed(event: UserRemoved): 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, chat_id=event.chat_id,
text=f'{event.from_user.first_name} кикнул {event.user.first_name} 😢' text=f'{event.from_user.first_name} кикнул {event.user.first_name} 😢'
) )
@@ -108,12 +126,52 @@ async def user_removed(event: UserRemoved):
@dp.user_added() @dp.user_added()
async def user_added(event: UserAdded): 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, chat_id=event.chat_id,
text=f'Чат "{event.chat.title}" приветствует вас, {event.user.first_name}!' text=f'Чат "{event.chat.title}" приветствует вас, {event.user.first_name}!'
) )
@dp.bot_stopped()
async def bot_stopped(event: BotStopped):
logging.info(event.from_user.full_name, 'остановил бота') # type: ignore
@dp.dialog_cleared()
async def dialog_cleared(event: DialogCleared):
logging.info(event.from_user.full_name, 'очистил историю чата с ботом') # type: ignore
@dp.dialog_muted()
async def dialog_muted(event: DialogMuted):
logging.info(event.from_user.full_name, 'отключил оповещения от чата бота до ', event.muted_until_datetime) # type: ignore
@dp.dialog_unmuted()
async def dialog_unmuted(event: DialogUnmuted):
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 bot.send_message(
chat_id=event.chat.chat_id,
text=f'Чат создан! Ссылка: {event.chat.link}'
)
async def main(): async def main():
await dp.start_polling(bot) await dp.start_polling(bot)

View File

@@ -44,7 +44,7 @@ async def echo(event: MessageCreated):
@dp.message_created(Command('builder')) @dp.message_created(Command('builder'))
async def echo(event: MessageCreated): async def builder(event: MessageCreated):
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row( builder.row(
@@ -54,8 +54,8 @@ async def echo(event: MessageCreated):
chat_description='Test desc' chat_description='Test desc'
), ),
LinkButton( LinkButton(
text="Канал разработчика", text="Документация MAX",
url="https://t.me/loveapples_dev" url="https://dev.max.ru/docs"
), ),
) )
@@ -88,7 +88,7 @@ async def echo(event: MessageCreated):
@dp.message_created(Command('payload')) @dp.message_created(Command('payload'))
async def echo(event: MessageCreated): async def payload(event: MessageCreated):
buttons = [ buttons = [
[ [
# кнопку типа "chat" убрали из документации, # кнопку типа "chat" убрали из документации,
@@ -99,8 +99,8 @@ async def echo(event: MessageCreated):
chat_description='Test desc' chat_description='Test desc'
), ),
LinkButton( LinkButton(
text="Канал разработчика", text="Документация MAX",
url="https://t.me/loveapples_dev" url="https://dev.max.ru/docs"
), ),
], ],
[ [
@@ -133,7 +133,7 @@ async def echo(event: MessageCreated):
@dp.message_chat_created() @dp.message_chat_created()
async def callback(obj: MessageChatCreated): async def message_chat_created(obj: MessageChatCreated):
await obj.bot.send_message( await obj.bot.send_message(
chat_id=obj.chat.chat_id, chat_id=obj.chat.chat_id,
text=f'Чат создан! Ссылка: {obj.chat.link}' text=f'Чат создан! Ссылка: {obj.chat.link}'
@@ -141,7 +141,7 @@ async def callback(obj: MessageChatCreated):
@dp.message_callback() @dp.message_callback()
async def callback(callback: MessageCallback): async def message_callback(callback: MessageCallback):
await callback.message.answer('Вы нажали на Callback!') await callback.message.answer('Вы нажали на Callback!')

View File

@@ -1,7 +1,7 @@
import asyncio import asyncio
import logging import logging
from typing import Any, Dict from typing import Any, Awaitable, Callable, Dict
from maxapi import Bot, Dispatcher from maxapi import Bot, Dispatcher
from maxapi.types import MessageCreated, Command, UpdateUnion from maxapi.types import MessageCreated, Command, UpdateUnion
@@ -16,13 +16,14 @@ dp = Dispatcher()
class CustomDataForRouterMiddleware(BaseMiddleware): class CustomDataForRouterMiddleware(BaseMiddleware):
async def __call__( async def __call__(
self, self,
event: UpdateUnion, handler: Callable[[Any, Dict[str, Any]], Awaitable[Any]],
data: Dict[str, Any] event_object: UpdateUnion,
): data: Dict[str, Any],
) -> Any:
data['custom_data'] = f'Это ID того кто вызвал команду: {event.from_user.user_id}' data['custom_data'] = f'Это ID того кто вызвал команду: {event_object.from_user.user_id}'
result = await handler(event_object, data)
return data return result
@dp.message_created(Command('custom_data')) @dp.message_created(Command('custom_data'))
@@ -31,9 +32,8 @@ async def custom_data(event: MessageCreated, custom_data: str):
async def main(): async def main():
dp.middlewares = [ dp.middleware(CustomDataForRouterMiddleware())
CustomDataForRouterMiddleware()
]
await dp.start_polling(bot) await dp.start_polling(bot)

View File

@@ -1,12 +1,11 @@
import asyncio import asyncio
import logging import logging
from typing import Any, Dict from typing import Any, Awaitable, Callable, Dict
from maxapi import Bot, Dispatcher from maxapi import Bot, Dispatcher
from maxapi.filters.middleware import BaseMiddleware from maxapi.filters.middleware import BaseMiddleware
from maxapi.types import MessageCreated, Command, UpdateUnion from maxapi.types import MessageCreated, Command, UpdateUnion
from maxapi.types.command import Command
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -17,27 +16,31 @@ dp = Dispatcher()
class CheckChatTitleMiddleware(BaseMiddleware): class CheckChatTitleMiddleware(BaseMiddleware):
async def __call__( async def __call__(
self, self,
event: UpdateUnion, handler: Callable[[Any, Dict[str, Any]], Awaitable[Any]],
): event_object: UpdateUnion,
data: Dict[str, Any],
) -> Any:
return event.chat.title == 'MAXApi' if event_object.chat.title == 'MAXApi':
return await handler(event_object, data)
@dp.message_created(Command('start'), CheckChatTitleMiddleware())
async def start(event: MessageCreated):
await event.message.answer('Это сообщение было отправлено, так как ваш чат называется "MAXApi"!')
class CustomDataMiddleware(BaseMiddleware): class CustomDataMiddleware(BaseMiddleware):
async def __call__( async def __call__(
self, self,
event: UpdateUnion, handler: Callable[[Any, Dict[str, Any]], Awaitable[Any]],
data: Dict[str, Any] event_object: UpdateUnion,
): data: Dict[str, Any],
) -> Any:
data['custom_data'] = f'Это ID того кто вызвал команду: {event.from_user.user_id}' data['custom_data'] = f'Это ID того кто вызвал команду: {event_object.from_user.user_id}'
return data await handler(event_object, data)
@dp.message_created(Command('start'), CheckChatTitleMiddleware())
async def start(event: MessageCreated):
await event.message.answer('Это сообщение было отправлено, так как ваш чат называется "MAXApi"!')
@dp.message_created(Command('custom_data'), CustomDataMiddleware()) @dp.message_created(Command('custom_data'), CustomDataMiddleware())

View File

@@ -16,7 +16,12 @@ async def handle_message(event: MessageCreated):
async def main(): async def main():
await dp.handle_webhook(bot) await dp.handle_webhook(
bot=bot,
host='localhost',
port=8080,
log_level='critical' # Можно убрать для подробного логгирования
)
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -1,13 +1,21 @@
import asyncio import asyncio
import logging import logging
from fastapi import Request try:
from fastapi.responses import JSONResponse 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]'
)
from maxapi import Bot, Dispatcher from maxapi import Bot, Dispatcher
from maxapi.methods.types.getted_updates import process_update_webhook from maxapi.methods.types.getted_updates import process_update_webhook
from maxapi.types import MessageCreated from maxapi.types import MessageCreated
from maxapi.dispatcher import webhook_app
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -21,7 +29,7 @@ async def handle_message(event: MessageCreated):
# Регистрация обработчика # Регистрация обработчика
# для вебхука # для вебхука
@webhook_app.post('/') @dp.webhook_post('/')
async def _(request: Request): async def _(request: Request):
# Сериализация полученного запроса # Сериализация полученного запроса

View File

@@ -3,8 +3,8 @@ from .dispatcher import Dispatcher, Router
from .filters import F from .filters import F
__all__ = [ __all__ = [
Bot, 'Bot',
Dispatcher, 'Dispatcher',
F, 'F',
Router 'Router'
] ]

File diff suppressed because it is too large Load Diff

27
maxapi/client/default.py Normal file
View File

@@ -0,0 +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

@@ -1,23 +1,25 @@
from __future__ import annotations
import os import os
import mimetypes import mimetypes
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, Optional
from uuid import uuid4
import aiofiles import aiofiles
import aiohttp
import puremagic import puremagic
from pydantic import BaseModel from pydantic import BaseModel
from aiohttp import ClientSession, ClientConnectionError, FormData
from ..exceptions.invalid_token import InvalidToken from ..exceptions.invalid_token import InvalidToken
from ..exceptions.max import MaxConnection
from ..types.errors import Error from ..types.errors import Error
from ..enums.http_method import HTTPMethod from ..enums.http_method import HTTPMethod
from ..enums.api_path import ApiPath from ..enums.api_path import ApiPath
from ..enums.upload_type import UploadType from ..enums.upload_type import UploadType
from ..loggers import logger_bot, logger_connection from ..loggers import logger_bot
if TYPE_CHECKING: if TYPE_CHECKING:
from ..bot import Bot from ..bot import Bot
@@ -28,43 +30,66 @@ class BaseConnection:
""" """
Базовый класс для всех методов API. Базовый класс для всех методов API.
Содержит общую логику выполнения запроса (например, сериализацию, отправку HTTP-запроса, обработку ответа). Содержит общую логику выполнения запроса (сериализация, отправка HTTP-запроса, обработка ответа).
Метод request() может быть переопределён в потомках при необходимости.
""" """
API_URL = 'https://botapi.max.ru' API_URL = 'https://botapi.max.ru'
RETRY_DELAY = 2
ATTEMPTS_COUNT = 5
AFTER_MEDIA_INPUT_DELAY = 2.0
def __init__(self): def __init__(self) -> None:
self.bot: 'Bot' = None
self.session: aiohttp.ClientSession = 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( async def request(
self, self,
method: HTTPMethod, method: HTTPMethod,
path: ApiPath, path: ApiPath | str,
model: BaseModel = None, model: BaseModel | Any = None,
is_return_raw: bool = False, is_return_raw: bool = False,
**kwargs **kwargs
): ):
""" """
Выполняет HTTP-запрос к API, используя указанные параметры. Выполняет HTTP-запрос к API.
:param method: HTTP-метод запроса (GET, POST и т.д.) Args:
:param path: Путь к конечной точке API method (HTTPMethod): HTTP-метод (GET, POST и т.д.).
:param model: Pydantic-модель, в которую будет десериализован ответ (если is_return_raw=False) path (ApiPath | str): Путь до конечной точки.
:param is_return_raw: Если True — вернуть "сырое" тело ответа, иначе — результат десериализации в model model (BaseModel | Any, optional): Pydantic-модель для десериализации ответа, если is_return_raw=False.
:param kwargs: Дополнительные параметры (например, query, headers, json) is_return_raw (bool, optional): Если True — вернуть сырой ответ, иначе — результат десериализации.
**kwargs: Дополнительные параметры (query, headers, json).
:return: Returns:
- Объект model (если is_return_raw=False и model задан) model | dict | Error: Объект модели, dict или ошибка.
- dict (если is_return_raw=True) Raises:
RuntimeError: Если бот не инициализирован.
MaxConnection: Ошибка соединения.
InvalidToken: Ошибка авторизации (401).
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
if not self.bot.session: if not self.bot.session:
self.bot.session = aiohttp.ClientSession(self.bot.API_URL) self.bot.session = ClientSession(
base_url=self.bot.API_URL,
timeout=self.bot.default_connection.timeout,
**self.bot.default_connection.kwargs
)
try: try:
r = await self.bot.session.request( r = await self.bot.session.request(
@@ -72,10 +97,11 @@ class BaseConnection:
url=path.value if isinstance(path, ApiPath) else path, url=path.value if isinstance(path, ApiPath) else path,
**kwargs **kwargs
) )
except aiohttp.ClientConnectorDNSError as e: except ClientConnectionError as e:
return logger_connection.error(f'Ошибка при отправке запроса: {e}') raise MaxConnection(f'Ошибка при отправке запроса: {e}')
if r.status == 401: if r.status == 401:
await self.bot.session.close()
raise InvalidToken('Неверный токен!') raise InvalidToken('Неверный токен!')
if not r.ok: if not r.ok:
@@ -86,9 +112,10 @@ class BaseConnection:
raw = await r.json() raw = await r.json()
if is_return_raw: return raw if is_return_raw:
return raw
model = model(**raw) model = model(**raw) # type: ignore
if hasattr(model, 'message'): if hasattr(model, 'message'):
attr = getattr(model, 'message') attr = getattr(model, 'message')
@@ -106,14 +133,17 @@ class BaseConnection:
path: str, path: str,
type: UploadType type: UploadType
): ):
""" """
Загружает файл на указанный URL. Загружает файл на сервер.
:param url: Конечная точка загрузки файла Args:
:param path: Путь к локальному файлу url (str): URL загрузки.
:param type: Тип файла (video, image, audio, file) path (str): Путь к файлу.
type (UploadType): Тип файла.
:return: Сырой .text() ответ от сервера после загрузки файла Returns:
str: Сырой .text() ответ от сервера.
""" """
async with aiofiles.open(path, 'rb') as f: async with aiofiles.open(path, 'rb') as f:
@@ -122,7 +152,7 @@ class BaseConnection:
basename = os.path.basename(path) basename = os.path.basename(path)
_, ext = os.path.splitext(basename) _, ext = os.path.splitext(basename)
form = aiohttp.FormData() form = FormData()
form.add_field( form.add_field(
name='data', name='data',
value=file_data, value=file_data,
@@ -130,7 +160,7 @@ class BaseConnection:
content_type=f"{type.value}/{ext.lstrip('.')}" content_type=f"{type.value}/{ext.lstrip('.')}"
) )
async with aiohttp.ClientSession() as session: async with ClientSession() as session:
response = await session.post( response = await session.post(
url=url, url=url,
data=form data=form
@@ -140,18 +170,23 @@ class BaseConnection:
async def upload_file_buffer( async def upload_file_buffer(
self, self,
filename: str,
url: str, url: str,
buffer: bytes, buffer: bytes,
type: UploadType type: UploadType
): ):
""" """
Загружает файл из буфера. Загружает файл из буфера.
:param url: Конечная точка загрузки файла Args:
:param buffer: Буфер (bytes) filename (str): Имя файла.
:param type: Тип файла (video, image, audio, file) url (str): URL загрузки.
buffer (bytes): Буфер данных.
type (UploadType): Тип файла.
:return: Сырой .text() ответ от сервера после загрузки файла Returns:
str: Сырой .text() ответ от сервера.
""" """
try: try:
@@ -166,9 +201,9 @@ class BaseConnection:
mime_type = f"{type.value}/*" mime_type = f"{type.value}/*"
ext = '' ext = ''
basename = f'{uuid4()}{ext}' basename = f'{filename}{ext}'
form = aiohttp.FormData() form = FormData()
form.add_field( form.add_field(
name='data', name='data',
value=buffer, value=buffer,
@@ -176,38 +211,9 @@ class BaseConnection:
content_type=mime_type content_type=mime_type
) )
async with aiohttp.ClientSession() as session: async with ClientSession() as session:
response = await session.post( response = await session.post(
url=url, url=url,
data=form data=form
) )
return await response.text() 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 aiohttp.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,93 +1,9 @@
import asyncio
from typing import Any, Dict
from ..context.state_machine import State, StatesGroup from ..context.state_machine import State, StatesGroup
from .context import MemoryContext
class MemoryContext: __all__ = [
'State',
""" 'StatesGroup',
Контекст хранения данных пользователя с блокировками. 'MemoryContext'
]
Args:
chat_id (int): Идентификатор чата
user_id (int): Идентификатор пользователя
"""
def __init__(self, chat_id: int, user_id: int):
self.chat_id = chat_id
self.user_id = user_id
self._context: Dict[str, Any] = {}
self._state: State | None = None
self._lock = asyncio.Lock()
async def get_data(self) -> dict[str, Any]:
"""
Возвращает текущий контекст данных.
Returns:
Словарь с данными контекста
"""
async with self._lock:
return self._context
async def set_data(self, data: dict[str, Any]):
"""
Полностью заменяет контекст данных.
Args:
data: Новый словарь контекста
"""
async with self._lock:
self._context = data
async def update_data(self, **kwargs):
"""
Обновляет контекст данных новыми значениями.
Args:
**kwargs: Пары ключ-значение для обновления
"""
async with self._lock:
self._context.update(kwargs)
async def set_state(self, state: State | str = None):
"""
Устанавливает новое состояние.
Args:
state: Новое состояние или None для сброса
"""
async with self._lock:
self._state = state
async def get_state(self):
"""
Возвращает текущее состояние.
Returns:
Текущее состояние или None
"""
async with self._lock:
return self._state
async def clear(self):
"""
Очищает контекст и сбрасывает состояние.
"""
async with self._lock:
self._state = None
self._context = {}

93
maxapi/context/context.py Normal file
View File

@@ -0,0 +1,93 @@
import asyncio
from typing import Any, Dict, Optional, Union
from ..context.state_machine import State
class MemoryContext:
"""
Контекст хранения данных пользователя с блокировками.
Args:
chat_id (int): Идентификатор чата
user_id (int): Идентификатор пользователя
"""
def __init__(self, chat_id: int, user_id: int):
self.chat_id = chat_id
self.user_id = user_id
self._context: Dict[str, Any] = {}
self._state: State | str | None = None
self._lock = asyncio.Lock()
async def get_data(self) -> dict[str, Any]:
"""
Возвращает текущий контекст данных.
Returns:
Словарь с данными контекста
"""
async with self._lock:
return self._context
async def set_data(self, data: dict[str, Any]):
"""
Полностью заменяет контекст данных.
Args:
data: Новый словарь контекста
"""
async with self._lock:
self._context = data
async def update_data(self, **kwargs):
"""
Обновляет контекст данных новыми значениями.
Args:
**kwargs: Пары ключ-значение для обновления
"""
async with self._lock:
self._context.update(kwargs)
async def set_state(self, state: Optional[Union[State, str]] = None):
"""
Устанавливает новое состояние.
Args:
state: Новое состояние или None для сброса
"""
async with self._lock:
self._state = state
async def get_state(self):
"""
Возвращает текущее состояние.
Returns:
Текущее состояние или None
"""
async with self._lock:
return self._state
async def clear(self):
"""
Очищает контекст и сбрасывает состояние.
"""
async with self._lock:
self._state = None
self._context = {}

View File

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

View File

@@ -2,13 +2,15 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any, Callable, Dict, List, TYPE_CHECKING import functools
from typing import Any, Awaitable, Callable, Dict, List, TYPE_CHECKING, Literal, Optional
from asyncio.exceptions import TimeoutError as AsyncioTimeoutError
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from uvicorn import Config, Server
from aiohttp import ClientConnectorError from aiohttp import ClientConnectorError
from maxapi.exceptions.dispatcher import HandlerException
from .filters.filter import BaseFilter
from .filters.middleware import BaseMiddleware from .filters.middleware import BaseMiddleware
from .filters.handler import Handler from .filters.handler import Handler
@@ -16,7 +18,7 @@ from .context import MemoryContext
from .types.updates import UpdateUnion from .types.updates import UpdateUnion
from .types.errors import Error from .types.errors import Error
from .methods.types.getted_updates import process_update_webhook, process_update_request from .methods.types.getted_updates import process_update_request, process_update_webhook
from .filters import filter_attrs from .filters import filter_attrs
@@ -24,12 +26,25 @@ from .bot import Bot
from .enums.update import UpdateType from .enums.update import UpdateType
from .loggers import logger_dp from .loggers import logger_dp
try:
from fastapi import FastAPI, Request # type: ignore
from fastapi.responses import JSONResponse # type: ignore
FASTAPI_INSTALLED = True
except ImportError:
FASTAPI_INSTALLED = False
try:
from uvicorn import Config, Server # type: ignore
UVICORN_INSTALLED = True
except ImportError:
UVICORN_INSTALLED = False
if TYPE_CHECKING: if TYPE_CHECKING:
from magic_filter import MagicFilter from magic_filter import MagicFilter
webhook_app = FastAPI()
CONNECTION_RETRY_DELAY = 30 CONNECTION_RETRY_DELAY = 30
GET_UPDATES_RETRY_DELAY = 5 GET_UPDATES_RETRY_DELAY = 5
@@ -43,25 +58,38 @@ class Dispatcher:
применение middleware, фильтров и вызов соответствующих обработчиков. применение middleware, фильтров и вызов соответствующих обработчиков.
""" """
def __init__(self): def __init__(self, router_id: str | None = None) -> None:
""" """
Инициализация диспетчера. Инициализация диспетчера.
Args:
router_id (str | None): Идентификатор роутера для логов.
""" """
self.router_id = router_id
self.event_handlers: List[Handler] = [] self.event_handlers: List[Handler] = []
self.contexts: List[MemoryContext] = [] self.contexts: List[MemoryContext] = []
self.routers: List[Router] = [] self.routers: List[Router | Dispatcher] = []
self.filters: List[MagicFilter] = [] self.filters: List[MagicFilter] = []
self.base_filters: List[BaseFilter] = []
self.middlewares: List[BaseMiddleware] = [] self.middlewares: List[BaseMiddleware] = []
self.bot: Bot = None self.bot: Optional[Bot] = None
self.on_started_func: Callable = None self.webhook_app: Optional[FastAPI] = None
self.on_started_func: Optional[Callable] = None
self.polling = False
self.message_created = Event(update_type=UpdateType.MESSAGE_CREATED, router=self) self.message_created = Event(update_type=UpdateType.MESSAGE_CREATED, router=self)
self.bot_added = Event(update_type=UpdateType.BOT_ADDED, router=self) self.bot_added = Event(update_type=UpdateType.BOT_ADDED, router=self)
self.bot_removed = Event(update_type=UpdateType.BOT_REMOVED, router=self) self.bot_removed = Event(update_type=UpdateType.BOT_REMOVED, router=self)
self.bot_started = Event(update_type=UpdateType.BOT_STARTED, router=self) self.bot_started = Event(update_type=UpdateType.BOT_STARTED, router=self)
self.bot_stopped = Event(update_type=UpdateType.BOT_STOPPED, router=self)
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.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_callback = Event(update_type=UpdateType.MESSAGE_CALLBACK, router=self)
self.message_chat_created = Event(update_type=UpdateType.MESSAGE_CHAT_CREATED, router=self) self.message_chat_created = Event(update_type=UpdateType.MESSAGE_CHAT_CREATED, router=self)
@@ -71,6 +99,23 @@ class Dispatcher:
self.user_removed = Event(update_type=UpdateType.USER_REMOVED, router=self) self.user_removed = Event(update_type=UpdateType.USER_REMOVED, router=self)
self.on_started = Event(update_type=UpdateType.ON_STARTED, router=self) self.on_started = Event(update_type=UpdateType.ON_STARTED, router=self)
def webhook_post(self, path: str):
def decorator(func):
if self.webhook_app is None:
try:
from fastapi import FastAPI # type: ignore
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_app = FastAPI()
return self.webhook_app.post(path)(func)
return decorator
async def check_me(self): async def check_me(self):
""" """
@@ -83,25 +128,90 @@ class Dispatcher:
logger_dp.info(f'Бот: @{me.username} first_name={me.first_name} id={me.user_id}') logger_dp.info(f'Бот: @{me.username} first_name={me.first_name} id={me.user_id}')
def build_middleware_chain(
self,
middlewares: list[BaseMiddleware],
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)
return handler
def include_routers(self, *routers: 'Router'): def include_routers(self, *routers: 'Router'):
""" """
Добавляет указанные роутеры в диспетчер. Добавляет указанные роутеры в диспетчер.
:param routers: Роутеры для добавления. Args:
*routers (Router): Роутеры для добавления.
""" """
self.routers += [r for r in routers] 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): async def __ready(self, bot: Bot):
""" """
Подготавливает диспетчер: сохраняет бота, регистрирует обработчики, вызывает on_started. Подготавливает диспетчер: сохраняет бота, регистрирует обработчики, вызывает on_started.
:param bot: Экземпляр бота. Args:
bot (Bot): Экземпляр бота.
""" """
self.bot = bot self.bot = bot
if self.polling and self.bot.auto_check_subscriptions:
response = await self.bot.get_subscriptions()
if response.subscriptions:
logger_subscriptions_text = ', '.join([s.url for s in response.subscriptions])
logger_dp.warning('БОТ ИГНОРИРУЕТ POLLING! Обнаружены установленные подписки: %s', logger_subscriptions_text)
await self.check_me() await self.check_me()
self.routers += [self] self.routers += [self]
@@ -116,11 +226,14 @@ class Dispatcher:
def __get_memory_context(self, chat_id: int, user_id: int): def __get_memory_context(self, chat_id: int, user_id: int):
""" """
Возвращает существующий или создает новый контекст по chat_id и user_id. Возвращает существующий или создаёт новый MemoryContext по chat_id и user_id.
:param chat_id: Идентификатор чата. Args:
:param user_id: Идентификатор пользователя. chat_id (int): Идентификатор чата.
:return: Объект MemoryContext. user_id (int): Идентификатор пользователя.
Returns:
MemoryContext: Контекст.
""" """
for ctx in self.contexts: for ctx in self.contexts:
@@ -131,44 +244,70 @@ class Dispatcher:
self.contexts.append(new_ctx) self.contexts.append(new_ctx)
return new_ctx return new_ctx
async def process_middlewares( async def call_handler(
self, self,
middlewares: List[BaseMiddleware], handler: Callable[[Any, dict[str, Any]], Awaitable[Any]],
event_object: UpdateUnion, event_object: UpdateType,
result_data_kwargs: Dict[str, Any] data: Dict[str, Any]
): ):
""" """
Последовательно обрабатывает middleware цепочку. Вызывает хендлер с нужными аргументами.
:param middlewares: Список middleware. Args:
:param event_object: Объект события. handler: Handler.
:param result_data_kwargs: Аргументы, передаваемые обработчику. event_object: Объект события.
:return: Изменённые аргументы или None. data: Данные для хендлера.
Returns:
None
""" """
for middleware in middlewares: func_args = handler.func_event.__annotations__.keys()
result = await middleware.process_middleware( kwargs_filtered = {k: v for k, v in data.items() if k in func_args}
event_object=event_object,
result_data_kwargs=result_data_kwargs
)
if result is None or result is False: if kwargs_filtered:
return await handler.func_event(event_object, **kwargs_filtered)
else:
await handler.func_event(event_object)
elif result is True: async def process_base_filters(
continue self,
event: UpdateUnion,
filters: List[BaseFilter]
) -> Optional[Dict[str, Any]] | Literal[False]:
result_data_kwargs.update(result) """
Асинхронно применяет фильтры к событию.
return result_data_kwargs 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): async def handle(self, event_object: UpdateUnion):
""" """
Основной обработчик события. Применяет фильтры, middleware и вызывает подходящий handler. Основной обработчик события. Применяет фильтры, middleware и вызывает нужный handler.
:param event_object: Событие, пришедшее в бот. Args:
event_object (UpdateUnion): Событие.
""" """
try: try:
@@ -176,24 +315,34 @@ class Dispatcher:
memory_context = self.__get_memory_context(*ids) memory_context = self.__get_memory_context(*ids)
current_state = await memory_context.get_state() current_state = await memory_context.get_state()
kwargs = {'context': memory_context} kwargs = {'context': memory_context}
router_id = None
process_info = f'{event_object.update_type} | chat_id: {ids[0]}, user_id: {ids[1]}'
is_handled = False is_handled = False
for router in self.routers: for index, router in enumerate(self.routers):
if is_handled: if is_handled:
break break
router_id = router.router_id or index
if router.filters: if router.filters:
if not filter_attrs(event_object, *router.filters): if not filter_attrs(event_object, *router.filters):
continue continue
kwargs = await self.process_middlewares( result_router_filter = await self.process_base_filters(
middlewares=router.middlewares, event=event_object,
event_object=event_object, filters=router.base_filters
result_data_kwargs=kwargs
) )
if isinstance(result_router_filter, dict):
kwargs.update(result_router_filter)
elif not result_router_filter:
continue
for handler in router.event_handlers: for handler in router.event_handlers:
if not handler.update_type == event_object.update_type: if not handler.update_type == event_object.update_type:
@@ -203,50 +352,82 @@ class Dispatcher:
if not filter_attrs(event_object, *handler.filters): if not filter_attrs(event_object, *handler.filters):
continue continue
if not handler.state == current_state and handler.state: if handler.states:
if current_state not in handler.states:
continue continue
func_args = handler.func_event.__annotations__.keys() func_args = handler.func_event.__annotations__.keys()
kwargs = await self.process_middlewares( if handler.base_filters:
middlewares=handler.middlewares, result_filter = await self.process_base_filters(
event_object=event_object, event=event_object,
result_data_kwargs=kwargs filters=handler.base_filters
) )
if not kwargs: if isinstance(result_filter, dict):
kwargs.update(result_filter)
elif not result_filter:
continue continue
for key in kwargs.copy().keys(): if isinstance(router, Router):
if not key in func_args: full_middlewares = self.middlewares + router.middlewares + handler.middlewares
del kwargs[key] elif isinstance(router, Dispatcher):
full_middlewares = self.middlewares + handler.middlewares
await handler.func_event(event_object, **kwargs) handler_chain = self.build_middleware_chain(
full_middlewares,
functools.partial(self.call_handler, handler)
)
logger_dp.info(f'Обработано: {event_object.update_type} | chat_id: {ids[0]}, user_id: {ids[1]}') 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: {router_id} | {process_info}')
is_handled = True is_handled = True
break break
if not is_handled: if not is_handled:
logger_dp.info(f'Проигнорировано: {event_object.update_type} | chat_id: {ids[0]}, user_id: {ids[1]}') logger_dp.info(f'Проигнорировано: router_id: {router_id} | {process_info}')
except Exception as e: except Exception as e:
logger_dp.error(f"Ошибка при обработке события: {event_object.update_type} | chat_id: {ids[0]}, user_id: {ids[1]} | {e} ") logger_dp.error(f"Ошибка при обработке события: router_id: {router_id} | {process_info} | {e} ")
async def start_polling(self, bot: Bot): async def start_polling(self, bot: Bot):
""" """
Запускает цикл получения обновлений с сервера (long polling). Запускает цикл получения обновлений (long polling).
:param bot: Экземпляр бота. Args:
bot (Bot): Экземпляр бота.
""" """
self.polling = True
await self.__ready(bot) await self.__ready(bot)
while True: if self.bot is None:
raise RuntimeError('Bot не инициализирован')
while self.polling:
try:
events: Dict = await self.bot.get_updates()
except AsyncioTimeoutError:
continue
try: try:
events = await self.bot.get_updates()
if isinstance(events, Error): if isinstance(events, Error):
logger_dp.info(f'Ошибка при получении обновлений: {events}, жду {GET_UPDATES_RETRY_DELAY} секунд') logger_dp.info(f'Ошибка при получении обновлений: {events}, жду {GET_UPDATES_RETRY_DELAY} секунд')
@@ -267,51 +448,80 @@ class Dispatcher:
logger_dp.error(f'Ошибка подключения, жду {CONNECTION_RETRY_DELAY} секунд') logger_dp.error(f'Ошибка подключения, жду {CONNECTION_RETRY_DELAY} секунд')
await asyncio.sleep(CONNECTION_RETRY_DELAY) await asyncio.sleep(CONNECTION_RETRY_DELAY)
except Exception as e: except Exception as e:
logger_dp.error(f'Общая ошибка при обработке событий: {e}') logger_dp.error(f'Общая ошибка при обработке событий: {e.__class__} - {e}')
async def handle_webhook(self, bot: Bot, host: str = '0.0.0.0', port: int = 8080): async def handle_webhook(self, bot: Bot, host: str = 'localhost', port: int = 8080, **kwargs):
""" """
Запускает FastAPI-приложение для приёма обновлений через вебхук. Запускает FastAPI-приложение для приёма обновлений через вебхук.
:param bot: Экземпляр бота. Args:
:param host: Хост, на котором запускается сервер. bot (Bot): Экземпляр бота.
:param port: Порт сервера. host (str): Хост сервера.
port (int): Порт сервера.
""" """
@webhook_app.post('/') if not FASTAPI_INSTALLED:
async def _(request: Request): raise ImportError(
try: '\n\t Не установлен fastapi!'
event_json = await request.json() '\n\t Выполните команду для установки fastapi: '
'\n\t pip install fastapi>=0.68.0'
'\n\t Или сразу все зависимости для работы вебхука:'
'\n\t pip install maxapi[webhook]'
)
elif not UVICORN_INSTALLED:
raise ImportError(
'\n\t Не установлен uvicorn!'
'\n\t Выполните команду для установки uvicorn: '
'\n\t pip install uvicorn>=0.15.0'
'\n\t Или сразу все зависимости для работы вебхука:'
'\n\t pip install maxapi[webhook]'
)
@self.webhook_post('/')
async def _(request: Request):
event_json = await request.json()
event_object = await process_update_webhook( event_object = await process_update_webhook(
event_json=event_json, event_json=event_json,
bot=self.bot bot=bot
) )
await self.handle(event_object) await self.handle(event_object)
return JSONResponse(content={'ok': True}, status_code=200) return JSONResponse(content={'ok': True}, status_code=200)
except Exception as e:
logger_dp.error(f"Ошибка при обработке события: {event_json['update_type']}: {e}")
await self.init_serve( await self.init_serve(
bot=bot, bot=bot,
host=host, host=host,
port=port port=port,
**kwargs
) )
async def init_serve(self, bot: Bot, host: str = '0.0.0.0', port: int = 8080, **kwargs): async def init_serve(self, bot: Bot, host: str = 'localhost', port: int = 8080, **kwargs):
""" """
Запускает сервер для обработки входящих вебхуков. Запускает сервер для обработки вебхуков.
:param bot: Экземпляр бота. Args:
:param host: Хост, на котором запускается сервер. bot (Bot): Экземпляр бота.
:param port: Порт сервера. host (str): Хост.
port (int): Порт.
""" """
config = Config(app=webhook_app, host=host, port=port, **kwargs) if not UVICORN_INSTALLED:
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 self.webhook_app is None:
raise RuntimeError('webhook_app не инициализирован')
config = Config(app=self.webhook_app, host=host, port=port, **kwargs)
server = Server(config) server = Server(config)
await self.__ready(bot) await self.__ready(bot)
@@ -325,8 +535,16 @@ class Router(Dispatcher):
Роутер для группировки обработчиков событий. Роутер для группировки обработчиков событий.
""" """
def __init__(self): def __init__(self, router_id: str | None = None):
super().__init__()
"""
Инициализация роутера.
Args:
router_id (str | None): Идентификатор роутера для логов.
"""
super().__init__(router_id)
class Event: class Event:
@@ -340,8 +558,9 @@ class Event:
""" """
Инициализирует событие-декоратор. Инициализирует событие-декоратор.
:param update_type: Тип события (UpdateType). Args:
:param router: Роутер или диспетчер, в который регистрируется обработчик. update_type (UpdateType): Тип события.
router (Dispatcher | Router): Экземпляр роутера или диспетчера.
""" """
self.update_type = update_type self.update_type = update_type
@@ -352,7 +571,8 @@ class Event:
""" """
Регистрирует функцию как обработчик события. Регистрирует функцию как обработчик события.
:return: Исходная функция. Returns:
Callable: Исходная функция.
""" """
def decorator(func_event: Callable): def decorator(func_event: Callable):

View File

@@ -20,3 +20,4 @@ class ApiPath(str, Enum):
MEMBERS = '/members' MEMBERS = '/members'
ADMINS = '/admins' ADMINS = '/admins'
UPLOADS = '/uploads' UPLOADS = '/uploads'
SUBSCRIPTIONS = '/subscriptions'

View File

@@ -17,3 +17,4 @@ class AttachmentType(str, Enum):
CONTACT = 'contact' CONTACT = 'contact'
INLINE_KEYBOARD = 'inline_keyboard' INLINE_KEYBOARD = 'inline_keyboard'
LOCATION = 'location' LOCATION = 'location'
SHARE = 'share'

View File

@@ -20,6 +20,11 @@ class UpdateType(str, Enum):
MESSAGE_REMOVED = 'message_removed' MESSAGE_REMOVED = 'message_removed'
USER_ADDED = 'user_added' USER_ADDED = 'user_added'
USER_REMOVED = 'user_removed' USER_REMOVED = 'user_removed'
BOT_STOPPED = 'bot_stopped'
DIALOG_CLEARED = 'dialog_cleared'
DIALOG_MUTED = 'dialog_muted'
DIALOG_UNMUTED = 'dialog_unmuted'
DIALOG_REMOVED = 'dialog_removed'
# Для начинки диспатчера # Для начинки диспатчера
ON_STARTED = 'on_started' 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):
... ...

11
maxapi/exceptions/max.py Normal file
View File

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

View File

@@ -1,16 +1,26 @@
from magic_filter import MagicFilter from magic_filter import MagicFilter
from .filter import BaseFilter
F = MagicFilter() F = MagicFilter()
__all__ = [
'BaseFilter'
]
def filter_attrs(obj: object, *filters: MagicFilter) -> bool: def filter_attrs(obj: object, *filters: MagicFilter) -> bool:
""" """
Применяет один или несколько фильтров MagicFilter к объекту. Применяет один или несколько фильтров MagicFilter к объекту.
:param obj: Любой объект с атрибутами (например, event/message) Args:
:param filters: Один или несколько MagicFilter выражений obj (object): Объект, к которому применяются фильтры (например, event или message).
:return: True, если все фильтры возвращают True, иначе False *filters (MagicFilter): Один или несколько выражений MagicFilter.
Returns:
bool: True, если все фильтры возвращают True, иначе False.
""" """
try: try:
return all(f.resolve(obj) for f in filters) return all(f.resolve(obj) for f in filters)
except Exception: 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 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 ..filters.middleware import BaseMiddleware
from ..types.command import Command, CommandStart
from ..context.state_machine import State from ..context.state_machine import State
from ..enums.update import UpdateType from ..enums.update import UpdateType
@@ -18,7 +17,7 @@ class Handler:
""" """
Обработчик события. Обработчик события.
Позволяет связать функцию-обработчик с типом обновления, состоянием и набором фильтров. Связывает функцию-обработчик с типом события, состояниями и фильтрами.
""" """
def __init__( def __init__(
@@ -30,32 +29,31 @@ class Handler:
): ):
""" """
Инициализация обработчика. Создаёт обработчик события.
:param args: Список фильтров и состояний, в том числе: Args:
- MagicFilter — фильтр события, *args: Список фильтров (MagicFilter, State, Command, BaseFilter, BaseMiddleware).
- State — состояние FSM, func_event (Callable): Функция-обработчик.
- Command — команда для фильтрации по началу текста сообщения. update_type (UpdateType): Тип обновления.
:param func_event: Функция-обработчик события
:param update_type: Тип обновления (события), на которое подписан обработчик
:param kwargs: Дополнительные параметры (не используются)
""" """
self.func_event: Callable = func_event self.func_event: Callable = func_event
self.update_type: UpdateType = update_type self.update_type: UpdateType = update_type
self.filters = [] self.filters: Optional[List[MagicFilter]] = []
self.state: State = None self.base_filters: Optional[List[BaseFilter]] = []
self.states: Optional[List[State]] = []
self.middlewares: List[BaseMiddleware] = [] self.middlewares: List[BaseMiddleware] = []
for arg in args: for arg in args:
if isinstance(arg, MagicFilter): if isinstance(arg, MagicFilter):
self.filters.append(arg) self.filters.append(arg)
elif isinstance(arg, State): elif isinstance(arg, State):
self.state = arg 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): elif isinstance(arg, BaseMiddleware):
self.middlewares.append(arg) self.middlewares.append(arg)
elif isinstance(arg, BaseFilter):
self.base_filters.append(arg)
else: else:
logger_dp.info(f'Обнаружен неизвестный фильтр `{arg}` при ' logger_dp.info(
f'регистрации функции `{func_event.__name__}`') f'Неизвестный фильтр `{arg}` при регистрации `{func_event.__name__}`'
)

View File

@@ -1,27 +1,30 @@
from typing import Any, Dict from typing import Any, Callable, Awaitable
from ..types.updates import UpdateUnion
class BaseMiddleware: class BaseMiddleware:
def __init__(self):
...
async def process_middleware( """
Базовый класс для мидлварей.
Используется для обработки события до и после вызова хендлера.
"""
async def __call__(
self, self,
result_data_kwargs: Dict[str, Any], handler: Callable[[Any, dict[str, Any]], Awaitable[Any]],
event_object: UpdateUnion event_object: Any,
): data: dict[str, Any]
) -> Any:
# пока что заглушка """
if result_data_kwargs is None: Вызывает хендлер с переданным событием и данными.
return {}
kwargs_temp = {'data': result_data_kwargs.copy()} Args:
handler (Callable): Хендлер события.
event_object (Any): Событие.
data (dict): Дополнительные данные.
for key in kwargs_temp.copy().keys(): Returns:
if not key in self.__call__.__annotations__.keys(): Any: Результат работы хендлера.
del kwargs_temp[key] """
result: Dict[str, Any] = await self(event_object, **kwargs_temp) return await handler(event_object, data)
return result

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, Any, Dict, List, Optional
from .types.added_admin_chat import AddedListAdminChat from .types.added_admin_chat import AddedListAdminChat
from ..types.users import ChatAdmin from ..types.users import ChatAdmin
@@ -30,14 +30,14 @@ class AddAdminChat(BaseConnection):
bot: 'Bot', bot: 'Bot',
chat_id: int, chat_id: int,
admins: List[ChatAdmin], admins: List[ChatAdmin],
marker: int = None marker: Optional[int] = None
): ):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
self.admins = admins self.admins = admins
self.marker = marker self.marker = marker
async def request(self) -> AddedListAdminChat: async def fetch(self) -> AddedListAdminChat:
""" """
Выполняет HTTP POST запрос для добавления администраторов в чат. Выполняет HTTP POST запрос для добавления администраторов в чат.
@@ -48,7 +48,10 @@ class AddAdminChat(BaseConnection):
AddedListAdminChat: Результат операции с информацией об успешности. AddedListAdminChat: Результат операции с информацией об успешности.
""" """
json = {} if self.bot is None:
raise RuntimeError('Bot не инициализирован')
json: Dict[str, Any] = {}
json['admins'] = [admin.model_dump() for admin in self.admins] json['admins'] = [admin.model_dump() for admin in self.admins]
json['marker'] = self.marker json['marker'] = self.marker

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, Any, Dict, List
from ..methods.types.added_members_chat import AddedMembersChat from ..methods.types.added_members_chat import AddedMembersChat
@@ -34,7 +34,7 @@ class AddMembersChat(BaseConnection):
self.chat_id = chat_id self.chat_id = chat_id
self.user_ids = user_ids self.user_ids = user_ids
async def request(self) -> AddedMembersChat: async def fetch(self) -> AddedMembersChat:
""" """
Отправляет POST-запрос на добавление пользователей в чат. Отправляет POST-запрос на добавление пользователей в чат.
@@ -45,7 +45,10 @@ class AddMembersChat(BaseConnection):
AddedMembersChat: Результат операции с информацией об успешности добавления. AddedMembersChat: Результат операции с информацией об успешности добавления.
""" """
json = {} if self.bot is None:
raise RuntimeError('Bot не инициализирован')
json: Dict[str, Any] = {}
json['user_ids'] = self.user_ids json['user_ids'] = self.user_ids

View File

@@ -1,4 +1,6 @@
from typing import Any, Dict, List, TYPE_CHECKING from typing import Any, Dict, List, TYPE_CHECKING, Optional
from ..types.attachments.image import PhotoAttachmentRequestPayload
from ..types.users import User from ..types.users import User
from ..types.command import BotCommand from ..types.command import BotCommand
@@ -23,16 +25,16 @@ class ChangeInfo(BaseConnection):
name (str, optional): Новое имя бота name (str, optional): Новое имя бота
description (str, optional): Новое описание description (str, optional): Новое описание
commands (List[BotCommand], optional): Список команд commands (List[BotCommand], optional): Список команд
photo (Dict[str, Any], optional): Данные фото photo (PhotoAttachmentRequestPayload, optional): Данные фото
""" """
def __init__( def __init__(
self, self,
bot: 'Bot', bot: 'Bot',
name: str = None, name: Optional[str] = None,
description: str = None, description: Optional[str] = None,
commands: List[BotCommand] = None, commands: Optional[List[BotCommand]] = None,
photo: Dict[str, Any] = None photo: Optional[PhotoAttachmentRequestPayload] = None
): ):
self.bot = bot self.bot = bot
self.name = name self.name = name
@@ -40,7 +42,7 @@ class ChangeInfo(BaseConnection):
self.commands = commands self.commands = commands
self.photo = photo self.photo = photo
async def request(self) -> User: async def fetch(self) -> User:
"""Отправляет запрос на изменение информации о боте. """Отправляет запрос на изменение информации о боте.
@@ -48,12 +50,19 @@ class ChangeInfo(BaseConnection):
User: Объект с обновленными данными бота User: Объект с обновленными данными бота
""" """
json = {} if self.bot is None:
raise RuntimeError('Bot не инициализирован')
if self.name: json['name'] = self.name json: Dict[str, Any] = {}
if self.description: json['description'] = self.description
if self.commands: json['commands'] = [command.model_dump() for command in self.commands] if self.name:
if self.photo: json['photo'] = self.photo json['name'] = self.name
if self.description:
json['description'] = self.description
if self.commands:
json['commands'] = [command.model_dump() for command in self.commands]
if self.photo:
json['photo'] = self.photo.model_dump()
return await super().request( return await super().request(
method=HTTPMethod.PATCH, method=HTTPMethod.PATCH,

View File

@@ -30,7 +30,7 @@ class DeleteMeFromMessage(BaseConnection):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
async def request(self) -> DeletedBotFromChat: async def fetch(self) -> DeletedBotFromChat:
""" """
Отправляет DELETE-запрос для удаления бота из чата. Отправляет DELETE-запрос для удаления бота из чата.
@@ -39,6 +39,8 @@ class DeleteMeFromMessage(BaseConnection):
DeletedBotFromChat: Результат операции удаления. DeletedBotFromChat: Результат операции удаления.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.DELETE, method=HTTPMethod.DELETE,
path=ApiPath.CHATS + '/' + str(self.chat_id) + ApiPath.MEMBERS + ApiPath.ME, path=ApiPath.CHATS + '/' + str(self.chat_id) + ApiPath.MEMBERS + ApiPath.ME,

View File

@@ -29,7 +29,7 @@ class DeleteChat(BaseConnection):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
async def request(self) -> DeletedChat: async def fetch(self) -> DeletedChat:
""" """
Отправляет DELETE-запрос для удаления указанного чата. Отправляет DELETE-запрос для удаления указанного чата.
@@ -38,6 +38,9 @@ class DeleteChat(BaseConnection):
DeletedChat: Результат операции удаления чата. DeletedChat: Результат операции удаления чата.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.DELETE, method=HTTPMethod.DELETE,
path=ApiPath.CHATS.value + '/' + str(self.chat_id), path=ApiPath.CHATS.value + '/' + str(self.chat_id),

View File

@@ -29,7 +29,7 @@ class DeleteMessage(BaseConnection):
self.bot = bot self.bot = bot
self.message_id = message_id self.message_id = message_id
async def request(self) -> DeletedMessage: async def fetch(self) -> DeletedMessage:
""" """
Выполняет DELETE-запрос для удаления сообщения. Выполняет DELETE-запрос для удаления сообщения.
@@ -40,6 +40,9 @@ class DeleteMessage(BaseConnection):
DeletedMessage: Результат операции удаления сообщения. DeletedMessage: Результат операции удаления сообщения.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
params['message_id'] = self.message_id params['message_id'] = self.message_id

View File

@@ -19,18 +19,18 @@ class DeletePinMessage(BaseConnection):
Args: Args:
bot (Bot): Экземпляр бота для выполнения запроса. bot (Bot): Экземпляр бота для выполнения запроса.
chat_id (str): Идентификатор чата, из которого нужно удалить закреплённое сообщение. chat_id (int): Идентификатор чата, из которого нужно удалить закреплённое сообщение.
""" """
def __init__( def __init__(
self, self,
bot: 'Bot', bot: 'Bot',
chat_id: str, chat_id: int,
): ):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
async def request(self) -> DeletedPinMessage: async def fetch(self) -> DeletedPinMessage:
""" """
Выполняет DELETE-запрос для удаления закреплённого сообщения. Выполняет DELETE-запрос для удаления закреплённого сообщения.
@@ -39,6 +39,9 @@ class DeletePinMessage(BaseConnection):
DeletedPinMessage: Результат операции удаления закреплённого сообщения. DeletedPinMessage: Результат операции удаления закреплённого сообщения.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.DELETE, method=HTTPMethod.DELETE,
path=ApiPath.CHATS + '/' + str(self.chat_id) + ApiPath.PIN, path=ApiPath.CHATS + '/' + str(self.chat_id) + ApiPath.PIN,

View File

@@ -1,11 +1,5 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ..methods.types.deleted_pin_message import DeletedPinMessage
from ..enums.http_method import HTTPMethod
from ..enums.api_path import ApiPath
from ..enums.upload_type import UploadType
from ..connection.base import BaseConnection from ..connection.base import BaseConnection
@@ -36,7 +30,7 @@ class DownloadMedia(BaseConnection):
self.media_url = media_url self.media_url = media_url
self.media_token = media_token self.media_token = media_token
async def request(self) -> int: async def fetch(self) -> int:
""" """
Выполняет GET-запрос для скачивания медиафайла Выполняет GET-запрос для скачивания медиафайла

View File

@@ -1,9 +1,11 @@
from logging import getLogger from logging import getLogger
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, Dict, Optional
from collections import Counter from collections import Counter
from ..exceptions.max import MaxIconParamsException
from ..types.attachments.image import PhotoAttachmentRequestPayload from ..types.attachments.image import PhotoAttachmentRequestPayload
from ..types.chats import Chat from ..types.chats import Chat
@@ -37,10 +39,10 @@ class EditChat(BaseConnection):
self, self,
bot: 'Bot', bot: 'Bot',
chat_id: int, chat_id: int,
icon: PhotoAttachmentRequestPayload = None, icon: Optional[PhotoAttachmentRequestPayload] = None,
title: str = None, title: Optional[str] = None,
pin: str = None, pin: Optional[str] = None,
notify: bool = True, notify: Optional[bool] = None,
): ):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
@@ -49,7 +51,7 @@ class EditChat(BaseConnection):
self.pin = pin self.pin = pin
self.notify = notify self.notify = notify
async def request(self) -> Chat: async def fetch(self) -> Chat:
""" """
Выполняет PATCH-запрос для обновления параметров чата. Выполняет PATCH-запрос для обновления параметров чата.
@@ -62,24 +64,31 @@ class EditChat(BaseConnection):
Chat: Обновлённый объект чата. Chat: Обновлённый объект чата.
""" """
json = {} if self.bot is None:
raise RuntimeError('Bot не инициализирован')
json: Dict[str, Any] = {}
if self.icon: if self.icon:
dump = self.icon.model_dump() dump = self.icon.model_dump()
counter = Counter(dump.values()) counter = Counter(dump.values())
if not None in counter or \ if None not in counter or \
not counter[None] == 2: not counter[None] == 2:
return logger.error(
raise MaxIconParamsException(
'Все атрибуты модели Icon являются взаимоисключающими | ' 'Все атрибуты модели Icon являются взаимоисключающими | '
'https://dev.max.ru/docs-api/methods/PATCH/chats/-chatId-' 'https://dev.max.ru/docs-api/methods/PATCH/chats/-chatId-'
) )
json['icon'] = dump json['icon'] = dump
if self.title: json['title'] = self.title if self.title:
if self.pin: json['pin'] = self.pin json['title'] = self.title
if self.notify: json['notify'] = self.notify if self.pin:
json['pin'] = self.pin
if self.notify:
json['notify'] = self.notify
return await super().request( return await super().request(
method=HTTPMethod.PATCH, method=HTTPMethod.PATCH,

View File

@@ -1,21 +1,25 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import List, TYPE_CHECKING, Optional from ..types.errors import Error
from typing import Any, Dict, List, TYPE_CHECKING, Optional
from ..utils.message import process_input_media
from .types.edited_message import EditedMessage from .types.edited_message import EditedMessage
from ..types.message import NewMessageLink from ..types.message import NewMessageLink
from ..types.attachments.attachment import Attachment from ..types.attachments.attachment import Attachment
from ..types.input_media import InputMedia, InputMediaBuffer
from ..enums.parse_mode import ParseMode from ..enums.parse_mode import ParseMode
from ..enums.http_method import HTTPMethod from ..enums.http_method import HTTPMethod
from ..enums.api_path import ApiPath from ..enums.api_path import ApiPath
from ..connection.base import BaseConnection from ..connection.base import BaseConnection
from ..loggers import logger_bot
if TYPE_CHECKING: if TYPE_CHECKING:
from ..bot import Bot from ..bot import Bot
from ..types.input_media import InputMedia, InputMediaBuffer
class EditMessage(BaseConnection): class EditMessage(BaseConnection):
@@ -37,10 +41,10 @@ class EditMessage(BaseConnection):
self, self,
bot: Bot, bot: Bot,
message_id: str, message_id: str,
text: str = None, text: Optional[str] = None,
attachments: List[Attachment | InputMedia | InputMediaBuffer] = None, attachments: Optional[List[Attachment | InputMedia | InputMediaBuffer]] = None,
link: NewMessageLink = None, link: Optional[NewMessageLink] = None,
notify: bool = True, notify: Optional[bool] = None,
parse_mode: Optional[ParseMode] = None parse_mode: Optional[ParseMode] = None
): ):
self.bot = bot self.bot = bot
@@ -51,7 +55,7 @@ class EditMessage(BaseConnection):
self.notify = notify self.notify = notify
self.parse_mode = parse_mode self.parse_mode = parse_mode
async def request(self) -> EditedMessage: async def fetch(self) -> Optional[EditedMessage | Error]:
""" """
Выполняет PUT-запрос для обновления сообщения. Выполняет PUT-запрос для обновления сообщения.
@@ -62,23 +66,58 @@ class EditMessage(BaseConnection):
EditedMessage: Обновлённое сообщение. EditedMessage: Обновлённое сообщение.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
json = {} json: Dict[str, Any] = {'attachments': []}
params['message_id'] = self.message_id params['message_id'] = self.message_id
if not self.text is None: json['text'] = self.text if self.text is not None:
if self.attachments: json['attachments'] = \ json['text'] = self.text
[att.model_dump() for att in self.attachments]
if not self.link is None: json['link'] = self.link.model_dump()
if not self.notify is None: json['notify'] = self.notify
if not self.parse_mode is None: json['format'] = self.parse_mode.value
return await super().request( if self.attachments:
for att in self.attachments:
if isinstance(att, InputMedia) or isinstance(att, InputMediaBuffer):
input_media = await process_input_media(
base_connection=self,
bot=self.bot,
att=att
)
json['attachments'].append(
input_media.model_dump()
)
else:
json['attachments'].append(att.model_dump())
if self.link is not None:
json['link'] = self.link.model_dump()
if self.notify is not None:
json['notify'] = self.notify
if self.parse_mode is not None:
json['format'] = self.parse_mode.value
await asyncio.sleep(self.bot.after_input_media_delay)
response = None
for attempt in range(self.ATTEMPTS_COUNT):
response = await super().request(
method=HTTPMethod.PUT, method=HTTPMethod.PUT,
path=ApiPath.MESSAGES, path=ApiPath.MESSAGES,
model=EditedMessage, model=EditedMessage,
params=params, params=params,
json=json json=json
) )
if isinstance(response, Error):
if response.raw.get('code') == 'attachment.not.ready':
logger_bot.info(f'Ошибка при отправке загруженного медиа, попытка {attempt+1}, жду {self.RETRY_DELAY} секунды')
await asyncio.sleep(self.RETRY_DELAY)
continue
return response
return response

View File

@@ -30,7 +30,7 @@ class GetChatById(BaseConnection):
self.bot = bot self.bot = bot
self.id = id self.id = id
async def request(self) -> Chat: async def fetch(self) -> Chat:
""" """
Выполняет GET-запрос для получения данных чата. Выполняет GET-запрос для получения данных чата.
@@ -39,6 +39,9 @@ class GetChatById(BaseConnection):
Chat: Объект чата с полной информацией. Chat: Объект чата с полной информацией.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.GET, method=HTTPMethod.GET,
path=ApiPath.CHATS.value + '/' + str(self.id), path=ApiPath.CHATS.value + '/' + str(self.id),

View File

@@ -40,7 +40,7 @@ class GetChatByLink(BaseConnection):
if not self.link: if not self.link:
return return
async def request(self) -> Chat: async def fetch(self) -> Chat:
""" """
Выполняет GET-запрос для получения данных чата по ссылке. Выполняет GET-запрос для получения данных чата по ссылке.
@@ -49,6 +49,9 @@ class GetChatByLink(BaseConnection):
Chat: Объект с информацией о чате. Chat: Объект с информацией о чате.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.GET, method=HTTPMethod.GET,
path=ApiPath.CHATS.value + '/' + self.link[-1], path=ApiPath.CHATS.value + '/' + self.link[-1],

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Optional
from ..types.chats import Chats from ..types.chats import Chats
@@ -32,13 +32,13 @@ class GetChats(BaseConnection):
self, self,
bot: 'Bot', bot: 'Bot',
count: int = 50, count: int = 50,
marker: int = None marker: Optional[int] = None
): ):
self.bot = bot self.bot = bot
self.count = count self.count = count
self.marker = marker self.marker = marker
async def request(self) -> Chats: async def fetch(self) -> Chats:
""" """
Выполняет GET-запрос для получения списка чатов. Выполняет GET-запрос для получения списка чатов.
@@ -47,6 +47,9 @@ class GetChats(BaseConnection):
Chats: Объект с данными по списку чатов. Chats: Объект с данными по списку чатов.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
params['count'] = self.count params['count'] = self.count

View File

@@ -34,7 +34,7 @@ class GetListAdminChat(BaseConnection):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
async def request(self) -> GettedListAdminChat: async def fetch(self) -> GettedListAdminChat:
""" """
Выполняет GET-запрос для получения списка администраторов указанного чата. Выполняет GET-запрос для получения списка администраторов указанного чата.
@@ -43,6 +43,9 @@ class GetListAdminChat(BaseConnection):
GettedListAdminChat: Объект с информацией о администраторах чата. GettedListAdminChat: Объект с информацией о администраторах чата.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.GET, method=HTTPMethod.GET,
path=ApiPath.CHATS.value + '/' + str(self.chat_id) + ApiPath.MEMBERS + ApiPath.ADMINS, path=ApiPath.CHATS.value + '/' + str(self.chat_id) + ApiPath.MEMBERS + ApiPath.ADMINS,

View File

@@ -24,7 +24,7 @@ class GetMe(BaseConnection):
def __init__(self, bot: 'Bot'): def __init__(self, bot: 'Bot'):
self.bot = bot self.bot = bot
async def request(self) -> User: async def fetch(self) -> User:
""" """
Выполняет GET-запрос для получения данных о боте. Выполняет GET-запрос для получения данных о боте.
@@ -33,6 +33,9 @@ class GetMe(BaseConnection):
User: Объект пользователя с полной информацией. User: Объект пользователя с полной информацией.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.GET, method=HTTPMethod.GET,
path=ApiPath.ME, path=ApiPath.ME,

View File

@@ -34,7 +34,7 @@ class GetMeFromChat(BaseConnection):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
async def request(self) -> ChatMember: async def fetch(self) -> ChatMember:
""" """
Выполняет GET-запрос для получения информации о боте в указанном чате. Выполняет GET-запрос для получения информации о боте в указанном чате.
@@ -43,6 +43,9 @@ class GetMeFromChat(BaseConnection):
ChatMember: Информация о боте как участнике чата. ChatMember: Информация о боте как участнике чата.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.GET, method=HTTPMethod.GET,
path=ApiPath.CHATS + '/' + str(self.chat_id) + ApiPath.MEMBERS + ApiPath.ME, path=ApiPath.CHATS + '/' + str(self.chat_id) + ApiPath.MEMBERS + ApiPath.ME,

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List, Optional
from ..methods.types.getted_members_chat import GettedMembersChat from ..methods.types.getted_members_chat import GettedMembersChat
@@ -27,7 +27,7 @@ class GetMembersChat(BaseConnection):
Attributes: Attributes:
bot (Bot): Экземпляр бота. bot (Bot): Экземпляр бота.
chat_id (int): Идентификатор чата. chat_id (int): Идентификатор чата.
user_ids (List[str] | None): Список ID пользователей для фильтра. user_ids (List[int] | None): Список ID пользователей для фильтра.
marker (int | None): Позиция для пагинации. marker (int | None): Позиция для пагинации.
count (int | None): Максимальное количество участников. count (int | None): Максимальное количество участников.
""" """
@@ -36,9 +36,9 @@ class GetMembersChat(BaseConnection):
self, self,
bot: 'Bot', bot: 'Bot',
chat_id: int, chat_id: int,
user_ids: List[str] = None, user_ids: Optional[List[int]] = None,
marker: int = None, marker: Optional[int] = None,
count: int = None, count: Optional[int] = None,
): ):
self.bot = bot self.bot = bot
@@ -47,7 +47,7 @@ class GetMembersChat(BaseConnection):
self.marker = marker self.marker = marker
self.count = count self.count = count
async def request(self) -> GettedMembersChat: async def fetch(self) -> GettedMembersChat:
""" """
Выполняет GET-запрос для получения участников чата с опциональной фильтрацией. Выполняет GET-запрос для получения участников чата с опциональной фильтрацией.
@@ -58,13 +58,18 @@ class GetMembersChat(BaseConnection):
GettedMembersChat: Объект с данными по участникам чата. GettedMembersChat: Объект с данными по участникам чата.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
if self.user_ids: if self.user_ids:
self.user_ids = [str(user_id) for user_id in self.user_ids] params['user_ids'] = ','.join([str(user_id) for user_id in self.user_ids])
params['user_ids'] = ','.join(self.user_ids)
if self.marker: params['marker'] = self.marker if self.marker:
if self.count: params['marker'] = self.count params['marker'] = self.marker
if self.count:
params['marker'] = self.count
return await super().request( return await super().request(
method=HTTPMethod.GET, method=HTTPMethod.GET,

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

@@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List, Optional, Union
from ..types.message import Messages from ..types.message import Messages
from ..enums.http_method import HTTPMethod from ..enums.http_method import HTTPMethod
@@ -36,10 +36,10 @@ class GetMessages(BaseConnection):
def __init__( def __init__(
self, self,
bot: 'Bot', bot: 'Bot',
chat_id: int, chat_id: Optional[int] = None,
message_ids: List[str] = None, message_ids: Optional[List[str]] = None,
from_time: datetime | int = None, from_time: Optional[Union[datetime, int]] = None,
to_time: datetime | int = None, to_time: Optional[Union[datetime, int]] = None,
count: int = 50, count: int = 50,
): ):
self.bot = bot self.bot = bot
@@ -49,7 +49,7 @@ class GetMessages(BaseConnection):
self.to_time = to_time self.to_time = to_time
self.count = count self.count = count
async def request(self) -> Messages: async def fetch(self) -> Messages:
""" """
Выполняет GET-запрос для получения сообщений с учётом параметров фильтрации. Выполняет GET-запрос для получения сообщений с учётом параметров фильтрации.
@@ -60,9 +60,13 @@ class GetMessages(BaseConnection):
Messages: Объект с полученными сообщениями. Messages: Объект с полученными сообщениями.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
if self.chat_id: params['chat_id'] = self.chat_id if self.chat_id:
params['chat_id'] = self.chat_id
if self.message_ids: if self.message_ids:
params['message_ids'] = ','.join(self.message_ids) params['message_ids'] = ','.join(self.message_ids)

View File

@@ -29,7 +29,7 @@ class GetPinnedMessage(BaseConnection):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
async def request(self) -> GettedPin: async def fetch(self) -> GettedPin:
""" """
Выполняет GET-запрос для получения закреплённого сообщения. Выполняет GET-запрос для получения закреплённого сообщения.
@@ -38,6 +38,9 @@ class GetPinnedMessage(BaseConnection):
GettedPin: Объект с информацией о закреплённом сообщении. GettedPin: Объект с информацией о закреплённом сообщении.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.GET, method=HTTPMethod.GET,
path=ApiPath.CHATS + '/' + str(self.chat_id) + ApiPath.PIN, path=ApiPath.CHATS + '/' + str(self.chat_id) + ApiPath.PIN,

View File

@@ -0,0 +1,44 @@
from typing import TYPE_CHECKING
from ..methods.types.getted_subscriptions import GettedSubscriptions
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 GetSubscriptions(BaseConnection):
"""
Если ваш бот получает данные через WebHook, этот класс возвращает список всех подписок.
"""
def __init__(
self,
bot: 'Bot',
):
self.bot = bot
async def fetch(self) -> GettedSubscriptions:
"""
Отправляет запрос на получение списка всех подписок.
Returns:
GettedSubscriptions: Объект со списком подписок
"""
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request(
method=HTTPMethod.GET,
path=ApiPath.SUBSCRIPTIONS,
model=GettedSubscriptions,
params=self.bot.params
)

View File

@@ -1,6 +1,5 @@
from typing import TYPE_CHECKING from __future__ import annotations
from typing import TYPE_CHECKING, Dict
from ..types.updates import UpdateUnion
from ..enums.http_method import HTTPMethod from ..enums.http_method import HTTPMethod
from ..enums.api_path import ApiPath from ..enums.api_path import ApiPath
@@ -28,13 +27,13 @@ class GetUpdates(BaseConnection):
def __init__( def __init__(
self, self,
bot: 'Bot', bot: Bot,
limit: int = 100, limit: int = 100,
): ):
self.bot = bot self.bot = bot
self.limit = limit self.limit = limit
async def request(self) -> UpdateUnion: async def fetch(self) -> Dict:
""" """
Выполняет GET-запрос для получения обновлений с указанным лимитом. Выполняет GET-запрос для получения обновлений с указанным лимитом.
@@ -45,6 +44,9 @@ class GetUpdates(BaseConnection):
UpdateUnion: Объединённый тип данных обновлений. UpdateUnion: Объединённый тип данных обновлений.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
params['limit'] = self.limit params['limit'] = self.limit

View File

@@ -33,7 +33,7 @@ class GetUploadURL(BaseConnection):
self.bot = bot self.bot = bot
self.type = type self.type = type
async def request(self) -> GettedUploadUrl: async def fetch(self) -> GettedUploadUrl:
""" """
Выполняет POST-запрос для получения URL загрузки файла. Выполняет POST-запрос для получения URL загрузки файла.
@@ -44,6 +44,9 @@ class GetUploadURL(BaseConnection):
GettedUploadUrl: Результат с URL для загрузки. GettedUploadUrl: Результат с URL для загрузки.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
params['type'] = self.type.value params['type'] = self.type.value

View File

@@ -30,7 +30,7 @@ class GetVideo(BaseConnection):
self.bot = bot self.bot = bot
self.video_token = video_token self.video_token = video_token
async def request(self) -> Video: async def fetch(self) -> Video:
""" """
Выполняет GET-запрос для получения данных видео по токену. Выполняет GET-запрос для получения данных видео по токену.
@@ -39,6 +39,9 @@ class GetVideo(BaseConnection):
Video: Объект с информацией о видео. Video: Объект с информацией о видео.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.GET, method=HTTPMethod.GET,
path=ApiPath.VIDEOS.value + '/' + self.video_token, path=ApiPath.VIDEOS.value + '/' + self.video_token,

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, Dict, Optional
from .types.pinned_message import PinnedMessage from .types.pinned_message import PinnedMessage
@@ -35,14 +35,14 @@ class PinMessage(BaseConnection):
bot: 'Bot', bot: 'Bot',
chat_id: int, chat_id: int,
message_id: str, message_id: str,
notify: bool = True notify: Optional[bool] = None
): ):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
self.message_id = message_id self.message_id = message_id
self.notify = notify self.notify = notify
async def request(self) -> PinnedMessage: async def fetch(self) -> PinnedMessage:
""" """
Выполняет PUT-запрос для закрепления сообщения в чате. Выполняет PUT-запрос для закрепления сообщения в чате.
@@ -53,7 +53,10 @@ class PinMessage(BaseConnection):
PinnedMessage: Объект с информацией о закреплённом сообщении. PinnedMessage: Объект с информацией о закреплённом сообщении.
""" """
json = {} if self.bot is None:
raise RuntimeError('Bot не инициализирован')
json: Dict[str, Any] = {}
json['message_id'] = self.message_id json['message_id'] = self.message_id
json['notify'] = self.notify json['notify'] = self.notify

View File

@@ -38,7 +38,7 @@ class RemoveAdmin(BaseConnection):
self.chat_id = chat_id self.chat_id = chat_id
self.user_id = user_id self.user_id = user_id
async def request(self) -> RemovedAdmin: async def fetch(self) -> RemovedAdmin:
""" """
Выполняет DELETE-запрос для отмены прав администратора в чате. Выполняет DELETE-запрос для отмены прав администратора в чате.
@@ -47,6 +47,9 @@ class RemoveAdmin(BaseConnection):
RemovedAdmin: Объект с результатом отмены прав администратора. RemovedAdmin: Объект с результатом отмены прав администратора.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await super().request( return await super().request(
method=HTTPMethod.DELETE, method=HTTPMethod.DELETE,
path=ApiPath.CHATS + '/' + str(self.chat_id) + \ path=ApiPath.CHATS + '/' + str(self.chat_id) + \

View File

@@ -43,7 +43,7 @@ class RemoveMemberChat(BaseConnection):
self.user_id = user_id self.user_id = user_id
self.block = block self.block = block
async def request(self) -> RemovedMemberChat: async def fetch(self) -> RemovedMemberChat:
""" """
Выполняет DELETE-запрос для удаления пользователя из чата. Выполняет DELETE-запрос для удаления пользователя из чата.
@@ -54,6 +54,9 @@ class RemoveMemberChat(BaseConnection):
RemovedMemberChat: Результат удаления участника. RemovedMemberChat: Результат удаления участника.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
params['chat_id'] = self.chat_id params['chat_id'] = self.chat_id

View File

@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, Dict, Optional
from ..methods.types.sended_action import SendedAction from ..methods.types.sended_action import SendedAction
@@ -34,14 +34,14 @@ class SendAction(BaseConnection):
def __init__( def __init__(
self, self,
bot: 'Bot', bot: 'Bot',
chat_id: int = None, chat_id: Optional[int] = None,
action: SenderAction = SenderAction.TYPING_ON action: SenderAction = SenderAction.TYPING_ON
): ):
self.bot = bot self.bot = bot
self.chat_id = chat_id self.chat_id = chat_id
self.action = action self.action = action
async def request(self) -> SendedAction: async def fetch(self) -> SendedAction:
""" """
Выполняет POST-запрос для отправки действия в указанный чат. Выполняет POST-запрос для отправки действия в указанный чат.
@@ -50,7 +50,10 @@ class SendAction(BaseConnection):
SendedAction: Результат выполнения запроса. SendedAction: Результат выполнения запроса.
""" """
json = {} if self.bot is None:
raise RuntimeError('Bot не инициализирован')
json: Dict[str, Any] = {}
json['action'] = self.action.value json['action'] = self.action.value

View File

@@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any, Dict, Optional
from ..methods.types.sended_callback import SendedCallback from ..methods.types.sended_callback import SendedCallback
@@ -36,15 +36,15 @@ class SendCallback(BaseConnection):
self, self,
bot: 'Bot', bot: 'Bot',
callback_id: str, callback_id: str,
message: Message = None, message: Optional[Message] = None,
notification: str = None notification: Optional[str] = None
): ):
self.bot = bot self.bot = bot
self.callback_id = callback_id self.callback_id = callback_id
self.message = message self.message = message
self.notification = notification self.notification = notification
async def request(self) -> SendedCallback: async def fetch(self) -> SendedCallback:
""" """
Выполняет POST-запрос для отправки callback-ответа. Выполняет POST-запрос для отправки callback-ответа.
@@ -55,14 +55,19 @@ class SendCallback(BaseConnection):
SendedCallback: Объект с результатом отправки callback. SendedCallback: Объект с результатом отправки callback.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
params['callback_id'] = self.callback_id params['callback_id'] = self.callback_id
json = {} json: Dict[str, Any] = {}
if self.message: json['message'] = self.message.model_dump() if self.message:
if self.notification: json['notification'] = self.notification json['message'] = self.message.model_dump()
if self.notification:
json['notification'] = self.notification
return await super().request( return await super().request(
method=HTTPMethod.POST, method=HTTPMethod.POST,

View File

@@ -1,18 +1,16 @@
import asyncio import asyncio
from typing import List, TYPE_CHECKING, Optional from typing import Any, Dict, List, TYPE_CHECKING, Optional
from json import loads as json_loads from ..utils.message import process_input_media
from .types.sended_message import SendedMessage from .types.sended_message import SendedMessage
from ..types.attachments.upload import AttachmentPayload, AttachmentUpload
from ..types.errors import Error from ..types.errors import Error
from ..types.message import NewMessageLink from ..types.message import NewMessageLink
from ..types.input_media import InputMedia, InputMediaBuffer from ..types.input_media import InputMedia, InputMediaBuffer
from ..types.attachments.attachment import Attachment from ..types.attachments.attachment import Attachment
from ..enums.upload_type import UploadType
from ..enums.parse_mode import ParseMode from ..enums.parse_mode import ParseMode
from ..enums.http_method import HTTPMethod from ..enums.http_method import HTTPMethod
from ..enums.api_path import ApiPath from ..enums.api_path import ApiPath
@@ -25,10 +23,6 @@ if TYPE_CHECKING:
from ..bot import Bot from ..bot import Bot
RETRY_DELAY = 2
ATTEMPTS_COUNT = 5
class SendMessage(BaseConnection): class SendMessage(BaseConnection):
""" """
@@ -39,7 +33,7 @@ class SendMessage(BaseConnection):
chat_id (int, optional): Идентификатор чата, куда отправлять сообщение. chat_id (int, optional): Идентификатор чата, куда отправлять сообщение.
user_id (int, optional): Идентификатор пользователя, если нужно отправить личное сообщение. user_id (int, optional): Идентификатор пользователя, если нужно отправить личное сообщение.
text (str, optional): Текст сообщения. text (str, optional): Текст сообщения.
attachments (List[Attachment | InputMedia], optional): Список вложений к сообщению. attachments (List[Attachment | InputMedia | InputMediaBuffer], optional): Список вложений к сообщению.
link (NewMessageLink, optional): Связь с другим сообщением (например, ответ или пересылка). link (NewMessageLink, optional): Связь с другим сообщением (например, ответ или пересылка).
notify (bool, optional): Отправлять ли уведомление о сообщении. По умолчанию True. notify (bool, optional): Отправлять ли уведомление о сообщении. По умолчанию True.
parse_mode (ParseMode, optional): Режим разбора текста (например, Markdown, HTML). parse_mode (ParseMode, optional): Режим разбора текста (например, Markdown, HTML).
@@ -48,12 +42,12 @@ class SendMessage(BaseConnection):
def __init__( def __init__(
self, self,
bot: 'Bot', bot: 'Bot',
chat_id: int = None, chat_id: Optional[int] = None,
user_id: int = None, user_id: Optional[int] = None,
text: str = None, text: Optional[str] = None,
attachments: List[Attachment | InputMedia] = None, attachments: Optional[List[Attachment | InputMedia | InputMediaBuffer]] = None,
link: NewMessageLink = None, link: Optional[NewMessageLink] = None,
notify: bool = True, notify: Optional[bool] = None,
parse_mode: Optional[ParseMode] = None parse_mode: Optional[ParseMode] = None
): ):
self.bot = bot self.bot = bot
@@ -65,59 +59,7 @@ class SendMessage(BaseConnection):
self.notify = notify self.notify = notify
self.parse_mode = parse_mode self.parse_mode = parse_mode
async def __process_input_media( async def fetch(self) -> Optional[SendedMessage | Error]:
self,
att: InputMedia | InputMediaBuffer
):
# очень нестабильный метод независящий от модуля
# ждем обновлений MAX API
"""
Загружает файл вложения и формирует объект AttachmentUpload.
Args:
att (InputMedia): Объект вложения для загрузки.
Returns:
AttachmentUpload: Загруженное вложение с токеном.
"""
upload = await self.bot.get_upload_url(att.type)
if isinstance(att, InputMedia):
upload_file_response = await self.upload_file(
url=upload.url,
path=att.path,
type=att.type,
)
elif isinstance(att, InputMediaBuffer):
upload_file_response = await self.upload_file_buffer(
url=upload.url,
buffer=att.buffer,
type=att.type,
)
if att.type in (UploadType.VIDEO, UploadType.AUDIO):
token = upload.token
elif att.type == UploadType.FILE:
json_r = json_loads(upload_file_response)
token = json_r['token']
elif att.type == UploadType.IMAGE:
json_r = json_loads(upload_file_response)
json_r_keys = list(json_r['photos'].keys())
token = json_r['photos'][json_r_keys[0]]['token']
return AttachmentUpload(
type=att.type,
payload=AttachmentPayload(
token=token
)
)
async def request(self) -> SendedMessage:
""" """
Отправляет сообщение с вложениями (если есть), с обработкой задержки готовности вложений. Отправляет сообщение с вложениями (если есть), с обработкой задержки готовности вложений.
@@ -128,33 +70,53 @@ class SendMessage(BaseConnection):
SendedMessage или Error SendedMessage или Error
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy() params = self.bot.params.copy()
json = {'attachments': []} json: Dict[str, Any] = {'attachments': []}
if self.chat_id: params['chat_id'] = self.chat_id if self.chat_id:
elif self.user_id: params['user_id'] = self.user_id params['chat_id'] = self.chat_id
elif self.user_id:
params['user_id'] = self.user_id
json['text'] = self.text json['text'] = self.text
HAS_INPUT_MEDIA = False
if self.attachments: if self.attachments:
for att in self.attachments: for att in self.attachments:
if isinstance(att, InputMedia) or isinstance(att, InputMediaBuffer): if isinstance(att, (InputMedia, InputMediaBuffer)):
input_media = await self.__process_input_media(att) HAS_INPUT_MEDIA = True
input_media = await process_input_media(
base_connection=self,
bot=self.bot,
att=att
)
json['attachments'].append( json['attachments'].append(
input_media.model_dump() input_media.model_dump()
) )
else: else:
json['attachments'].append(att.model_dump()) json['attachments'].append(att.model_dump())
if not self.link is None: json['link'] = self.link.model_dump() if self.link is not None:
json['link'] = self.link.model_dump()
json['notify'] = self.notify json['notify'] = self.notify
if not self.parse_mode is None: json['format'] = self.parse_mode.value
if self.parse_mode is not None:
json['format'] = self.parse_mode.value
if HAS_INPUT_MEDIA:
await asyncio.sleep(self.bot.after_input_media_delay)
response = None response = None
for attempt in range(ATTEMPTS_COUNT): for attempt in range(self.ATTEMPTS_COUNT):
response = await super().request( response = await super().request(
method=HTTPMethod.POST, method=HTTPMethod.POST,
path=ApiPath.MESSAGES, path=ApiPath.MESSAGES,
@@ -165,8 +127,8 @@ class SendMessage(BaseConnection):
if isinstance(response, Error): if isinstance(response, Error):
if response.raw.get('code') == 'attachment.not.ready': if response.raw.get('code') == 'attachment.not.ready':
logger_bot.info(f'Ошибка при отправке загруженного медиа, попытка {attempt+1}, жду {RETRY_DELAY} секунды') logger_bot.info(f'Ошибка при отправке загруженного медиа, попытка {attempt+1}, жду {self.RETRY_DELAY} секунды')
await asyncio.sleep(RETRY_DELAY) await asyncio.sleep(self.RETRY_DELAY)
continue continue
return response return response

View File

@@ -0,0 +1,70 @@
from typing import TYPE_CHECKING, Any, Dict, List, Optional
from ..methods.types.subscribed import Subscribed
from ..enums.http_method import HTTPMethod
from ..enums.api_path import ApiPath
from ..enums.update import UpdateType
from ..connection.base import BaseConnection
if TYPE_CHECKING:
from ..bot import Bot
class SubscribeWebhook(BaseConnection):
"""
Подписывает бота на получение обновлений через WebHook.
После вызова этого метода бот будет получать уведомления о новых событиях в чатах на указанный URL.
Ваш сервер должен прослушивать один из следующих портов: `80`, `8080`, `443`, `8443`, `16384`-`32383`.
Args:
bot (Bot): Экземпляр бота для выполнения запроса.
url (str): URL HTTP(S)-эндпойнта вашего бота. Должен начинаться с http(s)://
update_types (Optional[List[str]]): Список типов обновлений, которые ваш бот хочет получать. Для полного списка типов см. объект
secret (str): От 5 до 256 символов. Cекрет, который должен быть отправлен в заголовке X-Max-Bot-Api-Secret в каждом запросе Webhook. Разрешены только символы A-Z, a-z, 0-9, и дефис. Заголовок рекомендован, чтобы запрос поступал из установленного веб-узла
"""
def __init__(
self,
bot: 'Bot',
url: str,
update_types: Optional[List[UpdateType]] = None,
secret: Optional[str] = None
):
self.bot = bot
self.url = url
self.update_types = update_types
self.secret = secret
async def fetch(self) -> Subscribed:
"""
Отправляет запрос на подписку бота на получение обновлений через WebHook
Returns:
Subscribed: Объект с информацией об операции
"""
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
json: Dict[str, Any] = {}
json['url'] = self.url
if self.update_types:
json['update_types'] = self.update_types
if self.secret:
json['secret'] = self.secret
return await super().request(
method=HTTPMethod.POST,
path=ApiPath.SUBSCRIPTIONS,
model=Subscribed,
params=self.bot.params,
json=json
)

View File

@@ -0,0 +1,16 @@
from typing import List
from pydantic import BaseModel
from ...types.subscription import Subscription
class GettedSubscriptions(BaseModel):
"""
Ответ API с отправленным сообщением.
Attributes:
message (Message): Объект отправленного сообщения.
"""
subscriptions: List[Subscription]

View File

@@ -1,9 +1,12 @@
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from ...utils.updates import enrich_event
from ...enums.update import UpdateType from ...enums.update import UpdateType
from ...types.updates.bot_added import BotAdded from ...types.updates.bot_added import BotAdded
from ...types.updates.bot_removed import BotRemoved from ...types.updates.bot_removed import BotRemoved
from ...types.updates.bot_started import BotStarted from ...types.updates.bot_started import BotStarted
from ...types.updates.bot_stopped import BotStopped
from ...types.updates.chat_title_changed import ChatTitleChanged from ...types.updates.chat_title_changed import ChatTitleChanged
from ...types.updates.message_callback import MessageCallback from ...types.updates.message_callback import MessageCallback
from ...types.updates.message_chat_created import MessageChatCreated from ...types.updates.message_chat_created import MessageChatCreated
@@ -12,125 +15,56 @@ from ...types.updates.message_edited import MessageEdited
from ...types.updates.message_removed import MessageRemoved from ...types.updates.message_removed import MessageRemoved
from ...types.updates.user_added import UserAdded from ...types.updates.user_added import UserAdded
from ...types.updates.user_removed import UserRemoved 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: if TYPE_CHECKING:
from ...bot import Bot from ...bot import Bot
UPDATE_MODEL_MAPPING = {
UpdateType.BOT_ADDED: BotAdded,
UpdateType.BOT_REMOVED: BotRemoved,
UpdateType.BOT_STARTED: BotStarted,
UpdateType.CHAT_TITLE_CHANGED: ChatTitleChanged,
UpdateType.MESSAGE_CALLBACK: MessageCallback,
UpdateType.MESSAGE_CHAT_CREATED: MessageChatCreated,
UpdateType.MESSAGE_CREATED: MessageCreated,
UpdateType.MESSAGE_EDITED: MessageEdited,
UpdateType.MESSAGE_REMOVED: MessageRemoved,
UpdateType.USER_ADDED: UserAdded,
UpdateType.USER_REMOVED: UserRemoved,
UpdateType.BOT_STOPPED: BotStopped,
UpdateType.DIALOG_CLEARED: DialogCleared,
UpdateType.DIALOG_MUTED: DialogMuted,
UpdateType.DIALOG_UNMUTED: DialogUnmuted,
UpdateType.DIALOG_REMOVED: DialogRemoved
}
async def get_update_model(event: dict, bot: 'Bot'): async def get_update_model(event: dict, bot: 'Bot'):
event_object = None update_type = event['update_type']
model_cls = UPDATE_MODEL_MAPPING.get(update_type)
match event['update_type']: if not model_cls:
raise ValueError(f'Unknown update type: {update_type}')
case UpdateType.BOT_ADDED: event_object = await enrich_event(
event_object = BotAdded(**event) event_object=model_cls(**event),
bot=bot
case UpdateType.BOT_REMOVED: )
event_object = BotRemoved(**event)
case UpdateType.BOT_STARTED:
event_object = BotStarted(**event)
case UpdateType.CHAT_TITLE_CHANGED:
event_object = ChatTitleChanged(**event)
case UpdateType.MESSAGE_CALLBACK:
event_object = MessageCallback(**event)
event_object.chat = await bot.get_chat_by_id(event_object.message.recipient.chat_id) \
if bot.auto_requests else None
event_object.from_user = event_object.callback.user
case UpdateType.MESSAGE_CHAT_CREATED:
event_object = MessageChatCreated(**event)
event_object.chat = event_object.chat
case UpdateType.MESSAGE_CREATED:
event_object = MessageCreated(**event)
event_object.chat = await bot.get_chat_by_id(event_object.message.recipient.chat_id) \
if bot.auto_requests else None
event_object.from_user = event_object.message.sender
case UpdateType.MESSAGE_EDITED:
event_object = MessageEdited(**event)
event_object.chat = await bot.get_chat_by_id(event_object.message.recipient.chat_id) \
if bot.auto_requests else None
event_object.from_user = event_object.message.sender
case UpdateType.MESSAGE_REMOVED:
event_object = MessageRemoved(**event)
event_object.chat = await bot.get_chat_by_id(event_object.chat_id) \
if bot.auto_requests else None
event_object.from_user = await bot.get_chat_member(
chat_id=event_object.chat_id,
user_id=event_object.user_id
) if bot.auto_requests else None
case UpdateType.USER_ADDED:
event_object = UserAdded(**event)
event_object.chat = await bot.get_chat_by_id(event_object.chat_id) \
if bot.auto_requests else None
event_object.from_user = event_object.user
case UpdateType.USER_REMOVED:
event_object = UserRemoved(**event)
event_object.chat = await bot.get_chat_by_id(event_object.chat_id) \
if bot.auto_requests else None
event_object.from_user = await bot.get_chat_member(
chat_id=event_object.chat_id,
user_id=event_object.admin_id
) if event_object.admin_id and \
bot.auto_requests else None
if event['update_type'] in (UpdateType.BOT_ADDED,
UpdateType.BOT_REMOVED,
UpdateType.BOT_STARTED,
UpdateType.CHAT_TITLE_CHANGED):
event_object.chat = await bot.get_chat_by_id(event_object.chat_id) \
if bot.auto_requests else None
event_object.from_user = event_object.user
if hasattr(event_object, 'bot'):
event_object.bot = bot
if hasattr(event_object, 'message'):
event_object.message.bot = bot
for attachment in event_object.message.body.attachments:
if hasattr(attachment, 'bot'):
attachment.bot = bot
return event_object return event_object
async def process_update_request(events: dict, bot: 'Bot'): async def process_update_request(events: dict, bot: 'Bot'):
events = [event for event in events['updates']] return [
await get_update_model(event, bot)
objects = [] for event in events['updates']
]
for event in events:
objects.append(
await get_update_model(
bot=bot,
event=event
)
)
return objects
async def process_update_webhook(event_json: dict, bot: 'Bot'): async def process_update_webhook(event_json: dict, bot: 'Bot'):

View File

@@ -3,5 +3,5 @@ from pydantic import BaseModel
class GettedUploadUrl(BaseModel): class GettedUploadUrl(BaseModel):
url: Optional[str] = None url: str
token: Optional[str] = None token: Optional[str] = None

View File

@@ -21,4 +21,4 @@ class SendedCallback(BaseModel):
bot: Optional[Any] = Field(default=None, exclude=True) bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING: if TYPE_CHECKING:
bot: Optional[Bot] bot: Optional[Bot] # type: ignore

View File

@@ -0,0 +1,16 @@
from typing import Optional
from pydantic import BaseModel
class Subscribed(BaseModel):
"""
Результат подписки на обновления на Webhook
Attributes:
success (bool): Статус успешности операции.
message (Optional[str]): Дополнительное сообщение или ошибка.
"""
success: bool
message: Optional[str] = None

View File

@@ -0,0 +1,16 @@
from typing import Optional
from pydantic import BaseModel
class Unsubscribed(BaseModel):
"""
Результат отписки от обновлений на Webhook
Attributes:
success (bool): Статус успешности операции.
message (Optional[str]): Дополнительное сообщение или ошибка.
"""
success: bool
message: Optional[str] = None

View File

@@ -0,0 +1,54 @@
from typing import TYPE_CHECKING
from ..methods.types.unsubscribed import Unsubscribed
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 UnsubscribeWebhook(BaseConnection):
"""
Отписывает бота от получения обновлений через WebHook. После вызова этого метода бот перестает получать уведомления о новых событиях, и доступна доставка уведомлений через API с длительным опросом.
Args:
bot (Bot): Экземпляр бота для выполнения запроса.
url (str): URL, который нужно удалить из подписок на WebHook
"""
def __init__(
self,
bot: 'Bot',
url: str,
):
self.bot = bot
self.url = url
async def fetch(self) -> Unsubscribed:
"""
Отправляет запрос на подписку бота на получение обновлений через WebHook
Returns:
Unsubscribed: Объект с информацией об операции
"""
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
params = self.bot.params.copy()
params['url'] = self.url
return await super().request(
method=HTTPMethod.DELETE,
path=ApiPath.SUBSCRIPTIONS,
model=Unsubscribed,
params=params,
)

View File

@@ -9,6 +9,10 @@ from ..types.updates.message_edited import MessageEdited
from ..types.updates.message_removed import MessageRemoved from ..types.updates.message_removed import MessageRemoved
from ..types.updates.user_added import UserAdded from ..types.updates.user_added import UserAdded
from ..types.updates.user_removed import UserRemoved from ..types.updates.user_removed import UserRemoved
from ..types.updates.bot_stopped import BotStopped
from ..types.updates.dialog_cleared import DialogCleared
from ..types.updates.dialog_muted import DialogMuted
from ..types.updates.dialog_unmuted import DialogUnmuted
from ..types.updates import UpdateUnion from ..types.updates import UpdateUnion
from ..types.attachments.attachment import Attachment from ..types.attachments.attachment import Attachment
@@ -24,43 +28,51 @@ from ..types.attachments.buttons.request_contact import RequestContactButton
from ..types.attachments.buttons.open_app_button import OpenAppButton from ..types.attachments.buttons.open_app_button import OpenAppButton
from ..types.attachments.buttons.request_geo_location_button import RequestGeoLocationButton from ..types.attachments.buttons.request_geo_location_button import RequestGeoLocationButton
from ..types.attachments.buttons.message_button import MessageButton from ..types.attachments.buttons.message_button import MessageButton
from ..types.message import Message from ..types.attachments.image import PhotoAttachmentRequestPayload
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 InputMedia
from .input_media import InputMediaBuffer from .input_media import InputMediaBuffer
__all__ = [ __all__ = [
CommandStart, 'NewMessageLink',
OpenAppButton, 'PhotoAttachmentRequestPayload',
Message, 'DialogUnmuted',
Attachment, 'DialogMuted',
InputMediaBuffer, 'DialogCleared',
MessageButton, 'BotStopped',
UpdateUnion, 'CommandStart',
InputMedia, 'OpenAppButton',
BotCommand, 'Message',
CallbackButton, 'Attachment',
ChatButton, 'InputMediaBuffer',
LinkButton, 'MessageButton',
RequestContactButton, 'UpdateUnion',
RequestGeoLocationButton, 'InputMedia',
Command, 'BotCommand',
PhotoAttachmentPayload, 'CallbackButton',
OtherAttachmentPayload, 'ChatButton',
ContactAttachmentPayload, 'LinkButton',
ButtonsPayload, 'RequestContactButton',
StickerAttachmentPayload, 'RequestGeoLocationButton',
BotAdded, 'Command',
BotRemoved, 'PhotoAttachmentPayload',
BotStarted, 'OtherAttachmentPayload',
ChatTitleChanged, 'ContactAttachmentPayload',
MessageCallback, 'ButtonsPayload',
MessageChatCreated, 'StickerAttachmentPayload',
MessageCreated, 'BotAdded',
MessageEdited, 'BotRemoved',
MessageRemoved, 'BotStarted',
UserAdded, 'ChatTitleChanged',
UserRemoved 'MessageCallback',
'MessageChatCreated',
'MessageCreated',
'MessageEdited',
'MessageRemoved',
'UserAdded',
'UserRemoved',
] ]

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

@@ -1,8 +1,6 @@
from typing import TYPE_CHECKING, Any, List, Optional, Union from typing import TYPE_CHECKING, Any, List, Optional, Union
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ...exceptions.download_file import NotAvailableForDownload
from ...types.attachments.upload import AttachmentUpload from ...types.attachments.upload import AttachmentUpload
from ...types.attachments.buttons import InlineButtonUnion from ...types.attachments.buttons import InlineButtonUnion
from ...types.users import User from ...types.users import User
@@ -112,33 +110,7 @@ class Attachment(BaseModel):
bot: Optional[Any] = Field(default=None, exclude=True) bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING: if TYPE_CHECKING:
bot: Optional[Bot] bot: Optional[Bot] # type: ignore
class Config: class Config:
use_enum_values = True 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,5 +1,7 @@
from typing import Literal, Optional from typing import Literal, Optional
from ...enums.attachment import AttachmentType
from .attachment import Attachment from .attachment import Attachment
@@ -9,9 +11,8 @@ class Audio(Attachment):
Вложение с типом аудио. Вложение с типом аудио.
Attributes: Attributes:
type (Literal['audio']): Тип вложения, всегда 'audio'.
transcription (Optional[str]): Транскрипция аудио (если есть). transcription (Optional[str]): Транскрипция аудио (если есть).
""" """
type: Literal['audio'] = 'audio' type: Literal[AttachmentType.AUDIO]
transcription: Optional[str] = None transcription: Optional[str] = None

View File

@@ -1,10 +1,11 @@
from typing import Literal 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: Полезная нагрузка кнопки (массив рядов кнопок) payload: Полезная нагрузка кнопки (массив рядов кнопок)
""" """
type: Literal['inline_keyboard'] = 'inline_keyboard' type: Literal[AttachmentType.INLINE_KEYBOARD]
payload: ButtonsPayload

View File

@@ -17,8 +17,7 @@ class ChatButton(Button):
""" """
type: ButtonType = ButtonType.CHAT type: ButtonType = ButtonType.CHAT
chat_title: Optional[str] = None chat_title: str
chat_description: Optional[str] = None chat_description: Optional[str] = None
start_payload: Optional[str] = None start_payload: Optional[str] = None
chat_title: Optional[str] = None
uuid: Optional[int] = None uuid: Optional[int] = None

View File

@@ -1,5 +1,3 @@
from pydantic import BaseModel
from ....enums.button_type import ButtonType from ....enums.button_type import ButtonType
from .button import Button from .button import Button

View File

@@ -1,5 +1,3 @@
from typing import Optional
from ....enums.button_type import ButtonType from ....enums.button_type import ButtonType
from .button import Button from .button import Button

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
from pydantic import BaseModel, field_validator from pydantic import BaseModel, Field, field_validator
from typing import Dict, List, Optional from typing import Dict, List, Optional
from datetime import datetime from datetime import datetime
@@ -96,7 +96,7 @@ class Chats(BaseModel):
marker (Optional[int]): Маркер для пагинации. Может быть None. marker (Optional[int]): Маркер для пагинации. Может быть None.
""" """
chats: List[Chat] = [] chats: List[Chat] = Field(default_factory=list)
marker: Optional[int] = None marker: Optional[int] = None

View File

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

View File

@@ -1,17 +1,12 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
import puremagic import puremagic
from ..enums.upload_type import UploadType from ..enums.upload_type import UploadType
if TYPE_CHECKING:
from io import BytesIO
class InputMedia: class InputMedia:
""" """
Класс для представления медиафайла. Класс для представления медиафайла.
@@ -21,16 +16,19 @@ class InputMedia:
""" """
def __init__(self, path: str): def __init__(self, path: str):
""" """
Инициализирует объект медиафайла. Инициализирует объект медиафайла.
Args: Args:
path (str): Путь к файлу. path (str): Путь к файлу.
""" """
self.path = path self.path = path
self.type = self.__detect_file_type(path) self.type = self.__detect_file_type(path)
def __detect_file_type(self, path: str) -> UploadType: def __detect_file_type(self, path: str) -> UploadType:
""" """
Определяет тип файла на основе его содержимого (MIME-типа). Определяет тип файла на основе его содержимого (MIME-типа).
@@ -40,6 +38,7 @@ class InputMedia:
Returns: Returns:
UploadType: Тип файла (VIDEO, IMAGE, AUDIO или FILE). UploadType: Тип файла (VIDEO, IMAGE, AUDIO или FILE).
""" """
with open(path, 'rb') as f: with open(path, 'rb') as f:
sample = f.read(4096) sample = f.read(4096)
@@ -66,25 +65,30 @@ class InputMedia:
class InputMediaBuffer: class InputMediaBuffer:
""" """
Класс для представления медиафайла из буфера. Класс для представления медиафайла из буфера.
Attributes: Attributes:
buffer (BytesIO): Буфер с содержимым файла. buffer (bytes): Буфер с содержимым файла.
type (UploadType): Тип файла, определенный по содержимому. type (UploadType): Тип файла, определенный по содержимому.
""" """
def __init__(self, buffer: BytesIO): def __init__(self, buffer: bytes, filename: str | None = None):
""" """
Инициализирует объект медиафайла из буфера. Инициализирует объект медиафайла из буфера.
Args: Args:
buffer (IO): Буфер с содержимым файла. buffer (IO): Буфер с содержимым файла.
filename (str): Название файла (по умолчанию присваивается uuid4).
""" """
self.filename = filename
self.buffer = buffer self.buffer = buffer
self.type = self.__detect_file_type(buffer) self.type = self.__detect_file_type(buffer)
def __detect_file_type(self, buffer: BytesIO) -> UploadType: def __detect_file_type(self, buffer: bytes) -> UploadType:
try: try:
matches = puremagic.magic_string(buffer) matches = puremagic.magic_string(buffer)
if matches: if matches:

View File

@@ -3,20 +3,14 @@ from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Any, Optional, List, Union, TYPE_CHECKING from typing import Any, Optional, List, Union, TYPE_CHECKING
from ..types.attachments import Attachments
from ..enums.text_style import TextStyle from ..enums.text_style import TextStyle
from ..enums.parse_mode import ParseMode from ..enums.parse_mode import ParseMode
from ..enums.chat_type import ChatType from ..enums.chat_type import ChatType
from ..enums.message_link_type import MessageLinkType from ..enums.message_link_type import MessageLinkType
from .attachments.attachment import Attachment 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 from .users import User
@@ -89,21 +83,10 @@ class MessageBody(BaseModel):
mid: str mid: str
seq: int seq: int
text: str = None text: Optional[str] = None
attachments: Optional[ attachments: Optional[
List[ List[Attachments]
Union[ ] = Field(default_factory=list) # type: ignore
AttachmentButton,
Audio,
Video,
File,
Image,
Sticker,
Share,
Location
]
]
] = []
markup: Optional[ markup: Optional[
List[ List[
@@ -111,7 +94,7 @@ class MessageBody(BaseModel):
MarkupLink, MarkupElement MarkupLink, MarkupElement
] ]
] ]
] = [] ] = Field(default_factory=list) # type: ignore
class MessageStat(BaseModel): class MessageStat(BaseModel):
@@ -164,19 +147,19 @@ class Message(BaseModel):
recipient: Recipient recipient: Recipient
timestamp: int timestamp: int
link: Optional[LinkedMessage] = None link: Optional[LinkedMessage] = None
body: Optional[MessageBody] = None body: MessageBody
stat: Optional[MessageStat] = None stat: Optional[MessageStat] = None
url: Optional[str] = None url: Optional[str] = None
bot: Optional[Any] = Field(default=None, exclude=True) bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING: if TYPE_CHECKING:
bot: Optional[Bot] bot: Optional[Bot] # type: ignore
async def answer( async def answer(
self, self,
text: str = None, text: Optional[str] = None,
attachments: List[Attachment | InputMedia | InputMediaBuffer] = None, attachments: Optional[List[Attachment | InputMedia | InputMediaBuffer]] = None,
link: NewMessageLink = None, link: Optional[NewMessageLink] = None,
notify: Optional[bool] = None, notify: Optional[bool] = None,
parse_mode: Optional[ParseMode] = None parse_mode: Optional[ParseMode] = None
): ):
@@ -195,6 +178,9 @@ class Message(BaseModel):
Any: Результат выполнения метода send_message бота. Any: Результат выполнения метода send_message бота.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await self.bot.send_message( return await self.bot.send_message(
chat_id=self.recipient.chat_id, chat_id=self.recipient.chat_id,
user_id=self.recipient.user_id, user_id=self.recipient.user_id,
@@ -207,8 +193,8 @@ class Message(BaseModel):
async def reply( async def reply(
self, self,
text: str = None, text: Optional[str] = None,
attachments: List[Attachment | InputMedia | InputMediaBuffer] = None, attachments: Optional[List[Attachment | InputMedia | InputMediaBuffer]] = None,
notify: Optional[bool] = None, notify: Optional[bool] = None,
parse_mode: Optional[ParseMode] = None parse_mode: Optional[ParseMode] = None
): ):
@@ -226,6 +212,9 @@ class Message(BaseModel):
Any: Результат выполнения метода send_message бота. Any: Результат выполнения метода send_message бота.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await self.bot.send_message( return await self.bot.send_message(
chat_id=self.recipient.chat_id, chat_id=self.recipient.chat_id,
user_id=self.recipient.user_id, user_id=self.recipient.user_id,
@@ -242,8 +231,8 @@ class Message(BaseModel):
async def forward( async def forward(
self, self,
chat_id, chat_id,
user_id: int = None, user_id: Optional[int] = None,
attachments: List[Attachment | InputMedia | InputMediaBuffer] = None, attachments: Optional[List[Attachment | InputMedia | InputMediaBuffer]] = None,
notify: Optional[bool] = None, notify: Optional[bool] = None,
parse_mode: Optional[ParseMode] = None parse_mode: Optional[ParseMode] = None
): ):
@@ -262,6 +251,9 @@ class Message(BaseModel):
Any: Результат выполнения метода send_message бота. Any: Результат выполнения метода send_message бота.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await self.bot.send_message( return await self.bot.send_message(
chat_id=chat_id, chat_id=chat_id,
user_id=user_id, user_id=user_id,
@@ -276,9 +268,9 @@ class Message(BaseModel):
async def edit( async def edit(
self, self,
text: str = None, text: Optional[str] = None,
attachments: List[Attachment | InputMedia | InputMediaBuffer] = None, attachments: Optional[List[Attachment | InputMedia | InputMediaBuffer]] = None,
link: NewMessageLink = None, link: Optional[NewMessageLink] = None,
notify: bool = True, notify: bool = True,
parse_mode: Optional[ParseMode] = None parse_mode: Optional[ParseMode] = None
): ):
@@ -297,6 +289,9 @@ class Message(BaseModel):
Any: Результат выполнения метода edit_message бота. Any: Результат выполнения метода edit_message бота.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await self.bot.edit_message( return await self.bot.edit_message(
message_id=self.body.mid, message_id=self.body.mid,
text=text, text=text,
@@ -331,6 +326,9 @@ class Message(BaseModel):
Any: Результат выполнения метода pin_message бота. Any: Результат выполнения метода pin_message бота.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
return await self.bot.pin_message( return await self.bot.pin_message(
chat_id=self.recipient.chat_id, chat_id=self.recipient.chat_id,
message_id=self.body.mid, message_id=self.body.mid,
@@ -352,7 +350,7 @@ class Messages(BaseModel):
bot: Optional[Any] = Field(default=None, exclude=True) bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING: if TYPE_CHECKING:
bot: Optional[Bot] bot: Optional[Bot] # type: ignore
class NewMessageLink(BaseModel): class NewMessageLink(BaseModel):

View File

@@ -0,0 +1,18 @@
from typing import List, Optional
from pydantic import BaseModel
class Subscription(BaseModel):
"""
Подписка для вебхука
Attributes:
url (str): URL вебхука
time (int): Unix-время, когда была создана подписка
update_types (List[str]): Типы обновлений, на которые подписан бот
"""
url: str
time: int
update_types: Optional[List[str]] = None

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Optional
from .update import Update from .update import Update
@@ -14,12 +14,14 @@ class BotAdded(Update):
Обновление, сигнализирующее о добавлении бота в чат. Обновление, сигнализирующее о добавлении бота в чат.
Attributes: Attributes:
chat_id (Optional[int]): Идентификатор чата, куда добавлен бот. chat_id (int): Идентификатор чата, куда добавлен бот.
user (User): Объект пользователя-бота. user (User): Объект пользователя-бота.
is_channel (bool): Указывает, был ли бот добавлен в канал или нет
""" """
chat_id: Optional[int] = None chat_id: int
user: User user: User
is_channel: bool
if TYPE_CHECKING: if TYPE_CHECKING:
bot: Optional[Bot] bot: Optional[Bot]

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Optional
from .update import Update from .update import Update
@@ -14,12 +14,14 @@ class BotRemoved(Update):
Обновление, сигнализирующее об удалении бота из чата. Обновление, сигнализирующее об удалении бота из чата.
Attributes: Attributes:
chat_id (Optional[int]): Идентификатор чата, из которого удалён бот. chat_id (int): Идентификатор чата, из которого удалён бот.
user (User): Объект пользователя-бота. user (User): Объект пользователя-бота.
is_channel (bool): Указывает, был ли пользователь добавлен в канал или нет
""" """
chat_id: Optional[int] = None chat_id: int
user: User user: User
is_channel: bool
if TYPE_CHECKING: if TYPE_CHECKING:
bot: Optional[Bot] bot: Optional[Bot]

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Optional
from .update import Update from .update import Update
@@ -14,13 +14,13 @@ class BotStarted(Update):
Обновление, сигнализирующее о первом старте бота. Обновление, сигнализирующее о первом старте бота.
Attributes: Attributes:
chat_id (Optional[int]): Идентификатор чата. chat_id (int): Идентификатор чата.
user (User): Пользователь (бот). user (User): Пользователь (бот).
user_locale (Optional[str]): Локаль пользователя. user_locale (Optional[str]): Локаль пользователя.
payload (Optional[str]): Дополнительные данные. payload (Optional[str]): Дополнительные данные.
""" """
chat_id: Optional[int] = None chat_id: int
user: User user: User
user_locale: Optional[str] = None user_locale: Optional[str] = None
payload: Optional[str] = None payload: Optional[str] = None

View File

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

View File

@@ -1,4 +1,4 @@
from typing import TYPE_CHECKING, Any, Optional from typing import TYPE_CHECKING, Optional
from .update import Update from .update import Update
@@ -16,12 +16,12 @@ class ChatTitleChanged(Update):
Attributes: Attributes:
chat_id (Optional[int]): Идентификатор чата. chat_id (Optional[int]): Идентификатор чата.
user (User): Пользователь, совершивший изменение. user (User): Пользователь, совершивший изменение.
title (Optional[str]): Новое название чата. title (str): Новое название чата.
""" """
chat_id: Optional[int] = None chat_id: int
user: User user: User
title: Optional[str] = None title: str
if TYPE_CHECKING: if TYPE_CHECKING:
bot: Optional[Bot] bot: Optional[Bot]

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

@@ -0,0 +1,40 @@
from typing import TYPE_CHECKING, Optional
from datetime import datetime
from .update import Update
from ...types.users import User
if TYPE_CHECKING:
from ...bot import Bot
class DialogMuted(Update):
"""
Обновление, сигнализирующее об отключении оповещений от бота.
Attributes:
chat_id (int): Идентификатор чата.
muted_until (int): Время до включения оповещений от бота.
user (User): Пользователь (бот).
user_locale (Optional[str]): Локаль пользователя.
"""
chat_id: int
muted_until: int
user: User
user_locale: Optional[str] = None
if TYPE_CHECKING:
bot: Optional[Bot]
@property
def muted_until_datetime(self):
try:
return datetime.fromtimestamp(self.muted_until // 1000)
except (OverflowError, OSError):
return datetime.max
def get_ids(self):
return (self.chat_id, self.user.user_id)

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

@@ -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 DialogUnmuted(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,29 +1,18 @@
from typing import List, Optional, TYPE_CHECKING, Union from typing import List, Optional
from pydantic import BaseModel, Field
from ...types.attachments import Attachments
from pydantic import BaseModel
from .update import Update from .update import Update
from ...enums.parse_mode import ParseMode from ...enums.parse_mode import ParseMode
from ...types.message import NewMessageLink from ...types.message import NewMessageLink
from ...types.attachments.share import Share
from ...types.callback import Callback from ...types.callback import Callback
from ...types.message import Message 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
if TYPE_CHECKING:
from ...bot import Bot
from ...types.chats import Chat
from ...types.users import User
class MessageForCallback(BaseModel): class MessageForCallback(BaseModel):
@@ -41,18 +30,8 @@ class MessageForCallback(BaseModel):
text: Optional[str] = None text: Optional[str] = None
attachments: Optional[ attachments: Optional[
List[ List[Attachments]
Union[ ] = Field(default_factory=list) # type: ignore
AttachmentButton,
Audio,
Video,
File,
Image,
Sticker,
Share
]
]
] = []
link: Optional[NewMessageLink] = None link: Optional[NewMessageLink] = None
notify: Optional[bool] = True notify: Optional[bool] = True
format: Optional[ParseMode] = None format: Optional[ParseMode] = None
@@ -86,9 +65,9 @@ class MessageCallback(Update):
async def answer( async def answer(
self, self,
notification: str = None, notification: Optional[str] = None,
new_text: str = None, new_text: Optional[str] = None,
link: NewMessageLink = None, link: Optional[NewMessageLink] = None,
notify: bool = True, notify: bool = True,
format: Optional[ParseMode] = None, format: Optional[ParseMode] = None,
): ):
@@ -107,6 +86,9 @@ class MessageCallback(Update):
Результат вызова send_callback бота. Результат вызова send_callback бота.
""" """
if self.bot is None:
raise RuntimeError('Bot не инициализирован')
message = MessageForCallback() message = MessageForCallback()
message.text = new_text message.text = new_text

Some files were not shown because too many files have changed in this diff Show More