Добавлены примеры и обновлена модель Update

This commit is contained in:
2025-06-21 02:05:33 +03:00
parent ab52abc474
commit ee58238261
32 changed files with 546 additions and 304 deletions

View File

@@ -1,6 +1,8 @@
from datetime import datetime
from typing import Any, Dict, List, TYPE_CHECKING
from maxapi.methods.download_media import DownloadMedia
from .methods.get_upload_url import GetUploadURL
from .methods.get_updates import GetUpdates
from .methods.remove_member_chat import RemoveMemberChat
@@ -570,7 +572,7 @@ class Bot(BaseConnection):
"""Получает участников чата.
:param chat_id: ID чата
:param user_ids: Фильтр по ID пользователей
:param user_ids: Список ID участников
:param marker: Маркер для пагинации
:param count: Количество участников
@@ -584,6 +586,28 @@ class Bot(BaseConnection):
marker=marker,
count=count,
).request()
async def get_chat_member(
self,
chat_id: int,
user_id: int,
) -> GettedMembersChat:
"""Получает участника чата.
:param chat_id: ID чата
:param user_id: ID участника
:return: Участник
"""
members = await self.get_chat_members(
chat_id=chat_id,
user_ids=[user_id]
)
if members.members:
return members.members[0]
async def add_chat_members(
self,
@@ -673,4 +697,28 @@ class Bot(BaseConnection):
return await ChangeInfo(
bot=self,
commands=list(commands)
).request()
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
).request()

View File

@@ -1,13 +1,19 @@
import os
from typing import TYPE_CHECKING
import aiofiles
import aiohttp
from pydantic import BaseModel
from ..exceptions.invalid_token import InvalidToken
from ..types.errors import Error
from ..enums.http_method import HTTPMethod
from ..enums.api_path import ApiPath
from ..enums.upload_type import UploadType
from ..loggers import logger_bot, logger_connection
if TYPE_CHECKING:
@@ -65,6 +71,9 @@ class BaseConnection:
)
except aiohttp.ClientConnectorDNSError as e:
return logger_connection.error(f'Ошибка при отправке запроса: {e}')
if r.status == 401:
raise InvalidToken('Неверный токен!')
if not r.ok:
raw = await r.json()
@@ -124,4 +133,33 @@ class BaseConnection:
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

@@ -2,6 +2,7 @@ from typing import Callable, List
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from magic_filter import MagicFilter
from uvicorn import Config, Server
from aiohttp import ClientConnectorError
@@ -33,6 +34,8 @@ class Dispatcher:
def __init__(self):
self.event_handlers: List[Handler] = []
self.contexts: List[MemoryContext] = []
self.routers: List[Router] = []
self.filters: List[MagicFilter] = []
self.bot = None
self.on_started_func = None
@@ -65,9 +68,24 @@ class Dispatcher:
"""
for router in routers:
for event in router.event_handlers:
self.event_handlers.append(event)
self.routers.append(router)
async def __ready(self, bot: Bot):
self.bot = bot
await self.check_me()
self.routers += [self]
handlers_count = 0
for router in self.routers:
for handler in router.event_handlers:
handlers_count += 1
logger_dp.info(f'{handlers_count} событий на обработку')
if self.on_started_func:
await self.on_started_func()
def __get_memory_context(self, chat_id: int, user_id: int):
"""Возвращает или создает контекст для чата и пользователя.
@@ -95,40 +113,51 @@ class Dispatcher:
Args:
event_object: Объект события для обработки
"""
ids = event_object.get_ids()
is_handled = False
for router in self.routers:
if is_handled:
break
if router.filters:
if not filter_attrs(event_object, *router.filters):
continue
for handler in router.event_handlers:
for handler in self.event_handlers:
if not handler.update_type == event_object.update_type:
continue
if handler.filters:
if not filter_attrs(event_object, *handler.filters):
if not handler.update_type == event_object.update_type:
continue
ids = event_object.get_ids()
if handler.filters:
if not filter_attrs(event_object, *handler.filters):
continue
memory_context = self.__get_memory_context(*ids)
if not handler.state == await memory_context.get_state() \
and handler.state:
continue
func_args = handler.func_event.__annotations__.keys()
memory_context = self.__get_memory_context(*ids)
if not handler.state == await memory_context.get_state() \
and handler.state:
continue
func_args = handler.func_event.__annotations__.keys()
kwargs = {'context': memory_context}
kwargs = {'context': memory_context}
for key in kwargs.copy().keys():
if not key in func_args:
del kwargs[key]
for key in kwargs.copy().keys():
if not key in func_args:
del kwargs[key]
if handler.middleware:
await handler.middleware()
await handler.func_event(event_object, **kwargs)
await handler.func_event(event_object, **kwargs)
logger_dp.info(f'Обработано: {event_object.update_type} | chat_id: {ids[0]}, user_id: {ids[1]}')
logger_dp.info(f'Обработано: {event_object.update_type} | chat_id: {ids[0]}, user_id: {ids[1]}')
is_handled = True
break
is_handled = True
break
if not is_handled:
logger_dp.info(f'Проигнорировано: {event_object.update_type} | chat_id: {ids[0]}, user_id: {ids[1]}')
@@ -140,14 +169,7 @@ class Dispatcher:
Args:
bot: Экземпляр бота
"""
self.bot = bot
await self.check_me()
logger_dp.info(f'{len(self.event_handlers)} событий на обработку')
if self.on_started_func:
await self.on_started_func()
await self.__ready(bot)
while True:
try:
@@ -184,11 +206,7 @@ class Dispatcher:
port: Порт для сервера
"""
self.bot = bot
await self.check_me()
if self.on_started_func:
await self.on_started_func()
await self.__ready(bot)
@app.post('/')
async def _(request: Request):
@@ -206,7 +224,6 @@ class Dispatcher:
except Exception as e:
logger_dp.error(f"Ошибка при обработке события: {event_json['update_type']}: {e}")
logger_dp.info(f'{len(self.event_handlers)} событий на обработку')
config = Config(app=app, host=host, port=port, log_level="critical")
server = Server(config)
@@ -231,8 +248,10 @@ class Event:
def __call__(self, *args, **kwargs):
def decorator(func_event: Callable):
if self.update_type == UpdateType.ON_STARTED:
self.router.on_started_func = func_event
else:
self.router.event_handlers.append(
Handler(

View File

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

View File

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

View File

@@ -6,48 +6,15 @@ from magic_filter.operations.comparator import ComparatorOperation as mf_compara
F = MagicFilter()
def filter_attrs(obj, *magic_args):
def filter_attrs(obj: object, *filters: MagicFilter) -> bool:
"""
Применяет один или несколько фильтров MagicFilter к объекту.
:param obj: Любой объект с атрибутами (например, event/message)
:param filters: Один или несколько MagicFilter выражений
:return: True, если все фильтры возвращают True, иначе False
"""
try:
for arg in magic_args:
attr_last = None
method_found = False
operations = arg._operations
if isinstance(operations[-1], mf_call):
operations = operations[:len(operations)-2]
method_found = True
elif isinstance(operations[-1], mf_func):
operations = operations[:len(operations)-1]
method_found = True
elif isinstance(operations[-1], mf_comparator):
operations = operations[:len(operations)-1]
for element in operations:
if attr_last is None:
attr_last = getattr(obj, element.name)
else:
attr_last = getattr(attr_last, element.name)
if attr_last is None:
break
if isinstance(arg._operations[-1], mf_comparator):
return attr_last == arg._operations[-1].right
if not method_found:
return bool(attr_last)
if attr_last is None:
return False
if isinstance(arg._operations[-1], mf_func):
func_operation: mf_func = arg._operations[-1]
return func_operation.resolve(attr_last, attr_last)
else:
method = getattr(attr_last, arg._operations[-2].name)
args = arg._operations[-1].args
return method(*args)
except Exception as e:
...
return all(f.resolve(obj) for f in filters)
except Exception:
return False

View File

@@ -2,9 +2,14 @@ from typing import Callable
from magic_filter import F, MagicFilter
from ..filters.middleware import BaseMiddleware
from ..types.command import Command
from ..context.state_machine import State
from ..enums.update import UpdateType
from ..loggers import logger_dp
@@ -36,10 +41,11 @@ class Handler:
:param kwargs: Дополнительные параметры (не используются)
"""
self.func_event = func_event
self.update_type = update_type
self.func_event: Callable = func_event
self.update_type: UpdateType = update_type
self.filters = []
self.state = None
self.state: State = None
self.middleware: BaseMiddleware = None
for arg in args:
if isinstance(arg, MagicFilter):
@@ -48,6 +54,8 @@ class Handler:
self.state = arg
elif isinstance(arg, Command):
self.filters.insert(0, F.message.body.text.startswith(arg.command))
elif isinstance(arg, BaseMiddleware):
self.middleware = arg
else:
logger_dp.info(f'Обнаружен неизвестный фильтр `{arg}` при '
f'регистрации функции `{func_event.__name__}`')

View File

@@ -0,0 +1,6 @@
from ..types.updates import UpdateUnion
class BaseMiddleware:
def __init__(self):
...

View File

@@ -0,0 +1,52 @@
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
if TYPE_CHECKING:
from ..bot import Bot
class DownloadMedia(BaseConnection):
"""
Класс для скачивания медиафайлов.
Args:
bot (Bot): Экземпляр бота для выполнения запроса.
media_url (str): Ссылка на медиа.
media_token (str): Токен медиа.
"""
def __init__(
self,
bot: 'Bot',
path: str,
media_url: str,
media_token: str
):
self.bot = bot
self.path = path
self.media_url = media_url
self.media_token = media_token
async def request(self) -> int:
"""
Выполняет GET-запрос для скачивания медиафайла
Returns:
int: Код операции.
"""
return await super().download_file(
path=self.path,
url=self.media_url,
token=self.media_token
)

View File

@@ -60,7 +60,9 @@ class GetMembersChat(BaseConnection):
params = self.bot.params.copy()
if self.user_ids: params['user_ids'] = ','.join(self.user_ids)
if self.user_ids:
self.user_ids = [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.count: params['marker'] = self.count

View File

@@ -19,34 +19,77 @@ if TYPE_CHECKING:
async def get_update_model(event: dict, bot: 'Bot'):
event_object = None
match event['update_type']:
case UpdateType.BOT_ADDED:
event_object = BotAdded(**event)
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)
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)
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)
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)
event_object.from_user = await bot.get_chat_member(
chat_id=event_object.chat_id,
user_id=event_object.user_id
)
case UpdateType.USER_ADDED:
event_object = UserAdded(**event)
event_object.chat = await bot.get_chat_by_id(event_object.chat_id)
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)
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 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)
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

View File

@@ -1,14 +1,19 @@
from typing import List, Optional, Union
from pydantic import BaseModel
from typing import TYPE_CHECKING, Any, List, Optional, Union
from pydantic import BaseModel, Field
from ...exceptions.download_file import NotAvailableForDownload
from ...types.attachments.upload import AttachmentUpload
from ...types.attachments.buttons import InlineButtonUnion
from ...types.users import User
from ...enums.attachment import AttachmentType
if TYPE_CHECKING:
from ...bot import Bot
class StickerAttachmentPayload(BaseModel):
"""
@@ -98,6 +103,36 @@ class Attachment(BaseModel):
ButtonsPayload,
StickerAttachmentPayload
]] = None
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]
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,7 +1,5 @@
from typing import TYPE_CHECKING, Any, Optional
from pydantic import Field
from .update import Update
from ...types.users import User
@@ -18,12 +16,10 @@ class BotAdded(Update):
Attributes:
chat_id (Optional[int]): Идентификатор чата, куда добавлен бот.
user (User): Объект пользователя-бота.
bot (Optional[Any]): Ссылка на экземпляр бота, не сериализуется.
"""
chat_id: Optional[int] = None
user: User
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]

View File

@@ -1,7 +1,5 @@
from typing import TYPE_CHECKING, Any, Optional
from pydantic import Field
from .update import Update
from ...types.users import User
@@ -18,12 +16,10 @@ class BotRemoved(Update):
Attributes:
chat_id (Optional[int]): Идентификатор чата, из которого удалён бот.
user (User): Объект пользователя-бота.
bot (Optional[Any]): Ссылка на экземпляр бота, не сериализуется.
"""
chat_id: Optional[int] = None
user: User
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]

View File

@@ -1,8 +1,7 @@
from typing import TYPE_CHECKING, Any, Optional
from pydantic import Field
from .update import Update
from ...types.users import User
if TYPE_CHECKING:
@@ -19,14 +18,12 @@ class BotStarted(Update):
user (User): Пользователь (бот).
user_locale (Optional[str]): Локаль пользователя.
payload (Optional[str]): Дополнительные данные.
bot (Optional[Any]): Ссылка на экземпляр бота, не сериализуется.
"""
chat_id: Optional[int] = None
user: User
user_locale: Optional[str] = None
payload: Optional[str] = None
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]

View File

@@ -1,7 +1,5 @@
from typing import TYPE_CHECKING, Any, Optional
from pydantic import Field
from .update import Update
from ...types.users import User
@@ -19,13 +17,11 @@ class ChatTitleChanged(Update):
chat_id (Optional[int]): Идентификатор чата.
user (User): Пользователь, совершивший изменение.
title (Optional[str]): Новое название чата.
bot (Optional[Any]): Ссылка на экземпляр бота, не сериализуется.
"""
chat_id: Optional[int] = None
user: User
title: Optional[str] = None
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]

View File

@@ -1,6 +1,6 @@
from typing import Any, List, Optional, TYPE_CHECKING, Union
from typing import List, Optional, TYPE_CHECKING, Union
from pydantic import BaseModel, Field
from pydantic import BaseModel
from .update import Update
@@ -21,6 +21,8 @@ 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):
@@ -65,16 +67,11 @@ class MessageCallback(Update):
message (Message): Сообщение, на которое пришёл callback.
user_locale (Optional[str]): Локаль пользователя.
callback (Callback): Объект callback.
bot (Optional[Any]): Экземпляр бота, не сериализуется.
"""
message: Message
user_locale: Optional[str] = None
callback: Callback
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]
def get_ids(self):
@@ -89,7 +86,7 @@ class MessageCallback(Update):
async def answer(
self,
notification: str,
notification: str = None,
new_text: str = None,
link: NewMessageLink = None,
notify: bool = True,

View File

@@ -1,24 +1,15 @@
from typing import TYPE_CHECKING, Any, Optional
from pydantic import Field
from typing import Optional
from ...types.chats import Chat
from .update import Update
if TYPE_CHECKING:
from ...bot import Bot
class MessageChatCreated(Update):
chat: Chat
title: Optional[str] = None
message_id: Optional[str] = None
start_payload: Optional[str] = None
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]
def get_ids(self):
return (self.chat_id, 0)
return (self.chat.chat_id, self.chat.owner_id)

View File

@@ -1,15 +1,10 @@
from __future__ import annotations
from typing import Any, Optional, TYPE_CHECKING
from pydantic import Field
from typing import Optional, TYPE_CHECKING
from .update import Update
from ...types.message import Message
if TYPE_CHECKING:
from ...bot import Bot
class MessageCreated(Update):
@@ -19,16 +14,11 @@ class MessageCreated(Update):
Attributes:
message (Message): Объект сообщения.
user_locale (Optional[str]): Локаль пользователя.
bot (Optional[Any]): Экземпляр бота, не сериализуется.
"""
message: Message
user_locale: Optional[str] = None
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]
def get_ids(self):
"""

View File

@@ -1,14 +1,7 @@
from typing import TYPE_CHECKING, Any, Optional
from pydantic import Field
from .update import Update
from ...types.message import Message
if TYPE_CHECKING:
from ...bot import Bot
class MessageEdited(Update):
@@ -17,15 +10,10 @@ class MessageEdited(Update):
Attributes:
message (Message): Объект измененного сообщения.
bot (Optional[Any]): Экземпляр бота, не сериализуется.
"""
message: Message
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]
def get_ids(self):
"""

View File

@@ -1,12 +1,7 @@
from typing import TYPE_CHECKING, Any, Optional
from pydantic import Field
from typing import Optional
from .update import Update
if TYPE_CHECKING:
from ...bot import Bot
class MessageRemoved(Update):
@@ -17,16 +12,11 @@ class MessageRemoved(Update):
message_id (Optional[str]): Идентификатор удаленного сообщения. Может быть None.
chat_id (Optional[int]): Идентификатор чата. Может быть None.
user_id (Optional[int]): Идентификатор пользователя. Может быть None.
bot (Optional[Bot]): Объект бота, исключается из сериализации.
"""
message_id: Optional[str] = None
chat_id: Optional[int] = None
user_id: Optional[int] = None
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]
def get_ids(self):

View File

@@ -1,7 +1,14 @@
from pydantic import BaseModel
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Optional
from pydantic import BaseModel, Field
from ...enums.update import UpdateType
if TYPE_CHECKING:
from ...bot import Bot
from ...types.chats import Chat
from ...types.users import User
class Update(BaseModel):
@@ -15,6 +22,15 @@ class Update(BaseModel):
update_type: UpdateType
timestamp: int
bot: Optional[Any] = Field(default=None, exclude=True)
from_user: Optional[Any] = Field(default=None, exclude=True)
chat: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]
from_user: Optional[User]
chat: Optional[Chat]
class Config:
arbitrary_types_allowed=True

View File

@@ -1,16 +1,10 @@
from typing import TYPE_CHECKING, Any, Optional
from pydantic import Field
from typing import Optional
from .update import Update
from ...types.users import User
if TYPE_CHECKING:
from ...bot import Bot
class UserAdded(Update):
"""
@@ -20,17 +14,12 @@ class UserAdded(Update):
inviter_id (Optional[int]): Идентификатор пользователя, добавившего нового участника. Может быть None.
chat_id (Optional[int]): Идентификатор чата. Может быть None.
user (User): Объект пользователя, добавленного в чат.
bot (Optional[Bot]): Объект бота, исключается из сериализации.
"""
inviter_id: Optional[int] = None
chat_id: Optional[int] = None
user: User
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]
def get_ids(self):
"""

View File

@@ -1,14 +1,9 @@
from typing import TYPE_CHECKING, Any, Optional
from pydantic import Field
from typing import Optional
from .update import Update
from ...types.users import User
if TYPE_CHECKING:
from ...bot import Bot
class UserRemoved(Update):
@@ -19,17 +14,12 @@ class UserRemoved(Update):
admin_id (Optional[int]): Идентификатор администратора, удалившего пользователя. Может быть None.
chat_id (Optional[int]): Идентификатор чата. Может быть None.
user (User): Объект пользователя, удаленного из чата.
bot (Optional[Bot]): Объект бота, исключается из сериализации.
"""
admin_id: Optional[int] = None
chat_id: Optional[int] = None
user: User
bot: Optional[Any] = Field(default=None, exclude=True)
if TYPE_CHECKING:
bot: Optional[Bot]
def get_ids(self):
"""