Compare commits

..

13 Commits

13 changed files with 399 additions and 101 deletions

View File

@@ -9,9 +9,12 @@ from .types.errors import Error
from .types.input_media import InputMedia, InputMediaBuffer from .types.input_media import InputMedia, InputMediaBuffer
from .connection.base import BaseConnection from .connection.base import BaseConnection
from .loggers import logger_bot
from .enums.parse_mode import ParseMode from .enums.parse_mode import ParseMode
from .enums.sender_action import SenderAction from .enums.sender_action import SenderAction
from .enums.upload_type import UploadType from .enums.upload_type import UploadType
from .enums.update import UpdateType
from .methods.add_admin_chat import AddAdminChat from .methods.add_admin_chat import AddAdminChat
from .methods.add_members_chat import AddMembersChat from .methods.add_members_chat import AddMembersChat
@@ -20,7 +23,7 @@ from .methods.delete_bot_from_chat import DeleteMeFromMessage
from .methods.delete_chat import DeleteChat from .methods.delete_chat import DeleteChat
from .methods.delete_message import DeleteMessage from .methods.delete_message import DeleteMessage
from .methods.delete_pin_message import DeletePinMessage from .methods.delete_pin_message import DeletePinMessage
from .methods.download_media import DownloadMedia # from .methods.download_media import DownloadMedia
from .methods.edit_chat import EditChat from .methods.edit_chat import EditChat
from .methods.edit_message import EditMessage from .methods.edit_message import EditMessage
from .methods.get_chat_by_id import GetChatById from .methods.get_chat_by_id import GetChatById
@@ -41,6 +44,12 @@ from .methods.remove_member_chat import RemoveMemberChat
from .methods.send_action import SendAction from .methods.send_action import SendAction
from .methods.send_callback import SendCallback from .methods.send_callback import SendCallback
from .methods.send_message import SendMessage from .methods.send_message import SendMessage
from .methods.get_subscriptions import GetSubscriptions
from .methods.types.getted_subscriptions import GettedSubscriptions
from .methods.subscribe_webhook import SubscribeWebhook
from .methods.types.subscribed import Subscribed
from .methods.types.unsubscribed import Unsubscribed
from .methods.unsubscribe_webhook import UnsubscribeWebhook
if TYPE_CHECKING: if TYPE_CHECKING:
from .types.attachments.attachment import Attachment from .types.attachments.attachment import Attachment
@@ -85,7 +94,8 @@ class Bot(BaseConnection):
notify: Optional[bool] = None, notify: Optional[bool] = None,
auto_requests: bool = True, auto_requests: bool = True,
default_connection: Optional[DefaultConnectionProperties] = None, default_connection: Optional[DefaultConnectionProperties] = None,
after_input_media_delay: Optional[float] = None after_input_media_delay: Optional[float] = None,
auto_check_subscriptions: bool = True
): ):
""" """
@@ -94,10 +104,10 @@ class Bot(BaseConnection):
:param token: Токен доступа к API бота :param token: Токен доступа к API бота
:param parse_mode: Форматирование по умолчанию :param parse_mode: Форматирование по умолчанию
:param notify: Отключение уведомлений при отправке сообщений (по умолчанию игнорируется) (не работает на стороне MAX) :param notify: Отключение уведомлений при отправке сообщений (по умолчанию игнорируется) (не работает на стороне MAX)
:param auto_requests: Автоматическое заполнение полей chat и from_user в Update :param auto_requests: Автоматическое заполнение полей chat и from_user в Update с помощью API запросов если они не заложены как полноценные объекты в Update (по умолчанию True, при False chat и from_user в некоторых событиях будут выдавать None)
:param default_connection: Настройки aiohttp :param default_connection: Настройки aiohttp
:param after_input_media_delay: Задержка в секундах после загрузки файла на сервера MAX (без этого чаще всего MAX не успевает обработать вложение и выдает ошибку `errors.process.attachment.file.not.processed`) :param after_input_media_delay: Задержка в секундах после загрузки файла на сервера MAX (без этого чаще всего MAX не успевает обработать вложение и выдает ошибку `errors.process.attachment.file.not.processed`)
с помощью API запросов если они не заложены как полноценные объекты в Update (по умолчанию True, при False chat и from_user в некоторых событиях будут выдавать None) :param auto_check_subscriptions: Проверка на установленные подписки для метода start_polling (бот не работает в поллинге при установленных подписках)
""" """
super().__init__() super().__init__()
@@ -105,6 +115,7 @@ class Bot(BaseConnection):
self.bot = self self.bot = self
self.default_connection = default_connection or DefaultConnectionProperties() self.default_connection = default_connection or DefaultConnectionProperties()
self.after_input_media_delay = after_input_media_delay or 2.0 self.after_input_media_delay = after_input_media_delay or 2.0
self.auto_check_subscriptions = auto_check_subscriptions
self.__token = token self.__token = token
self.params: Dict[str, Any] = {'access_token': self.__token} self.params: Dict[str, Any] = {'access_token': self.__token}
@@ -765,26 +776,100 @@ class Bot(BaseConnection):
commands=list(commands) commands=list(commands)
).fetch() ).fetch()
async def download_file( async def get_subscriptions(self) -> GettedSubscriptions:
"""
Получает список всех подписок.
:return: Объект со списком подписок
"""
return await GetSubscriptions(bot=self).fetch()
async def subscribe_webhook(
self, self,
path: str,
url: str, url: str,
token: str update_types: Optional[List[UpdateType]] = None,
): secret: Optional[str] = None
) -> Subscribed:
""" """
Скачивает медиа с указанной ссылки по токену, сохраняя по определенному пути Подписывает бота на получение обновлений через WebHook.
После вызова этого метода бот будет получать уведомления о новых событиях в чатах на указанный URL.
Ваш сервер должен прослушивать один из следующих портов: `80`, `8080`, `443`, `8443`, `16384`-`32383`.
:param path: Путь сохранения медиа :param url: URL HTTP(S)-эндпойнта вашего бота. Должен начинаться с http(s)://
:param url: Ссылка на медиа :param update_types: Список типов обновлений, которые ваш бот хочет получать.
:param token: Токен медиа Для полного списка типов см. объект
:param secret: От 5 до 256 символов. Cекрет, который должен быть отправлен в заголовке X-Max-Bot-Api-Secret
в каждом запросе Webhook. Разрешены только символы A-Z, a-z, 0-9, и дефис.
Заголовок рекомендован, чтобы запрос поступал из установленного веб-узла
:return: Числовой статус :return: Обновленная информация о боте
""" """
return await DownloadMedia( return await SubscribeWebhook(
bot=self, bot=self,
path=path, url=url,
media_url=url, update_types=update_types,
media_token=token secret=secret
).fetch() ).fetch()
async def unsubscribe_webhook(
self,
url: str,
) -> Unsubscribed:
"""
Отписывает бота от получения обновлений через WebHook.
После вызова этого метода бот перестает получать уведомления о новых событиях,
и доступна доставка уведомлений через API с длительным опросом.
:param url: URL HTTP(S)-эндпойнта вашего бота. Должен начинаться с http(s)://
:return: Обновленная информация о боте
"""
return await UnsubscribeWebhook(
bot=self,
url=url,
).fetch()
async def delete_webhook(self):
"""
Удаление всех подписок на Webhook
"""
subs = await self.get_subscriptions()
if subs.subscriptions:
for sub in subs.subscriptions:
await self.unsubscribe_webhook(sub.url)
logger_bot.info('Удалена подписка на Webhook: %s', sub.url)
# async def download_file(
# self,
# path: str,
# url: str,
# token: str
# ):
# """
# Скачивает медиа с указанной ссылки по токену, сохраняя по определенному пути
# :param path: Путь сохранения медиа
# :param url: Ссылка на медиа
# :param token: Токен медиа
# :return: Числовой статус
# """
# return await DownloadMedia(
# bot=self,
# path=path,
# media_url=url,
# media_token=token
# ).fetch()

View File

@@ -2,7 +2,8 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any, Callable, Dict, List, TYPE_CHECKING, Optional import functools
from typing import Any, Awaitable, Callable, Dict, List, TYPE_CHECKING, Optional
from asyncio.exceptions import TimeoutError as AsyncioTimeoutError from asyncio.exceptions import TimeoutError as AsyncioTimeoutError
from aiohttp import ClientConnectorError from aiohttp import ClientConnectorError
@@ -74,6 +75,7 @@ class Dispatcher:
self.bot: Optional[Bot] = None self.bot: Optional[Bot] = None
self.webhook_app: Optional[FastAPI] = None self.webhook_app: Optional[FastAPI] = None
self.on_started_func: Optional[Callable] = 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)
@@ -121,6 +123,17 @@ 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]]:
for mw in reversed(middlewares):
handler = functools.partial(mw, handler)
return handler
def include_routers(self, *routers: 'Router'): def include_routers(self, *routers: 'Router'):
""" """
@@ -141,6 +154,13 @@ class Dispatcher:
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]
@@ -170,37 +190,17 @@ 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, handler, event_object, data):
self,
middlewares: List[BaseMiddleware],
event_object: UpdateUnion,
result_data_kwargs: Dict[str, Any]
):
""" """
Последовательно обрабатывает middleware цепочку. Правка аргументов конечной функции хендлера и ее вызов
:param middlewares: Список middleware.
:param event_object: Объект события.
:param result_data_kwargs: Аргументы, передаваемые обработчику.
:return: Изменённые аргументы или 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: await handler.func_event(event_object, **kwargs_filtered)
return
elif result is True:
continue
result_data_kwargs.update(result)
return result_data_kwargs
async def handle(self, event_object: UpdateUnion): async def handle(self, event_object: UpdateUnion):
@@ -232,12 +232,6 @@ class Dispatcher:
if not filter_attrs(event_object, *router.filters): if not filter_attrs(event_object, *router.filters):
continue continue
kwargs = await self.process_middlewares(
middlewares=router.middlewares,
event_object=event_object,
result_data_kwargs=kwargs
)
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:
@@ -252,20 +246,19 @@ class Dispatcher:
func_args = handler.func_event.__annotations__.keys() func_args = handler.func_event.__annotations__.keys()
kwargs = await self.process_middlewares( if isinstance(router, Router):
middlewares=handler.middlewares, full_middlewares = self.middlewares + router.middlewares + handler.middlewares
event_object=event_object, elif isinstance(router, Dispatcher):
result_data_kwargs=kwargs full_middlewares = self.middlewares + handler.middlewares
handler_chain = self.build_middleware_chain(
full_middlewares,
functools.partial(self.call_handler, handler)
) )
if not kwargs: kwargs_filtered = {k: v for k, v in kwargs.items() if k in func_args}
continue
for key in kwargs.copy().keys(): await handler_chain(event_object, kwargs_filtered)
if key not in func_args:
del kwargs[key]
await handler.func_event(event_object, **kwargs)
logger_dp.info(f'Обработано: {router_id} | {process_info}') logger_dp.info(f'Обработано: {router_id} | {process_info}')
@@ -286,12 +279,14 @@ class Dispatcher:
:param bot: Экземпляр бота. :param bot: Экземпляр бота.
""" """
self.polling = True
await self.__ready(bot) await self.__ready(bot)
while True: if self.bot is None:
raise RuntimeError('Bot не инициализирован')
if self.bot is None: while self.polling:
raise RuntimeError('Bot не инициализирован')
try: try:
events: Dict = await self.bot.get_updates() events: Dict = await self.bot.get_updates()

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

@@ -1,27 +1,10 @@
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 __call__(
... self,
handler: Callable[[Any, dict[str, Any]], Awaitable[Any]],
async def process_middleware( event_object: Any,
self, data: dict[str, Any]
result_data_kwargs: Dict[str, Any], ) -> Any:
event_object: UpdateUnion return await handler(event_object, data)
):
# пока что заглушка
if result_data_kwargs is None:
return {}
kwargs_temp = {'data': result_data_kwargs.copy()}
for key in kwargs_temp.copy().keys():
if key not in self.__call__.__annotations__.keys(): # type: ignore
del kwargs_temp[key]
result: Dict[str, Any] = await self(event_object, **kwargs_temp) # type: ignore
return result

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

@@ -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

@@ -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

@@ -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

@@ -8,10 +8,10 @@ from ...types.users import User
class UserRemoved(Update): class UserRemoved(Update):
""" """
Класс для обработки события удаления пользователя из чата. Класс для обработки события выходе/удаления пользователя из чата.
Attributes: Attributes:
admin_id (Optional[int]): Идентификатор администратора, удалившего пользователя. Может быть None. admin_id (Optional[int]): Идентификатор администратора, удалившего пользователя. None при выходе из чата самим пользователем.
chat_id (int): Идентификатор чата. Может быть None. chat_id (int): Идентификатор чата. Может быть None.
user (User): Объект пользователя, удаленного из чата. user (User): Объект пользователя, удаленного из чата.
is_channel (bool): Указывает, был ли пользователь удален из канала или нет is_channel (bool): Указывает, был ли пользователь удален из канала или нет

View File

@@ -16,7 +16,7 @@
| `message_edited` | Сообщение было отредактировано | | `message_edited` | Сообщение было отредактировано |
| `message_removed` | Сообщение было удалено | | `message_removed` | Сообщение было удалено |
| `user_added` | Пользователь добавлен в чат | | `user_added` | Пользователь добавлен в чат |
| `user_removed` | Пользователь удалён из чата | | `user_removed` | Пользователь удалён/вышел из чата |
| `on_started` | Бот запущен (**внутреннее** событие библиотеки) | | `on_started` | Бот запущен (**внутреннее** событие библиотеки) |
--- ---