Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions maxapi/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -519,19 +519,23 @@ async def get_messages(
message_ids: list[str] | None = None,
from_time: datetime | int | None = None,
to_time: datetime | int | None = None,
count: int = 50,
count: int | None = 50,
) -> Messages:
"""
Получает сообщения из чата.
Получает сообщения из чата или по списку идентификаторов.

Нужно передать ровно один из параметров: `chat_id`
или `message_ids`.

https://dev.max.ru/docs-api/methods/GET/messages

Args:
chat_id: ID чата.
message_ids: ID сообщений.
chat_id: ID чата, из которого нужно получить сообщения.
message_ids: ID сообщений, которые нужно получить.
from_time: Начало периода.
to_time: Конец периода.
count: Количество сообщений.
count: Количество сообщений. Если None, параметр
не отправляется.

Returns:
Messages: Список сообщений.
Expand Down Expand Up @@ -659,12 +663,12 @@ async def get_chats(

async def get_chat_by_link(self, link: str) -> Chat:
"""
Получает чат по ссылке.
Получает канал по публичной ссылке или алиасу.

https://dev.max.ru/docs-api/methods/GET/chats/-chatLink-

Args:
link: Ссылка на чат.
link: Публичная ссылка или алиас канала.

Returns:
Chat: Объект чата.
Expand Down
25 changes: 18 additions & 7 deletions maxapi/methods/get_chat_by_link.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from re import findall
from re import fullmatch
from typing import TYPE_CHECKING, cast
from urllib.parse import urlparse

from ..connection.base import BaseConnection
from ..enums.api_path import ApiPath
Expand All @@ -12,12 +13,12 @@

class GetChatByLink(BaseConnection):
"""
Класс для получения информации о чате по ссылке.
Класс для получения информации о канале по публичной ссылке.

https://dev.max.ru/docs-api/methods/GET/chats/-chatLink-

Attributes:
link: Список валидных частей ссылки.
link: Нормализованная публичная ссылка.
PATTERN_LINK: Регулярное выражение для парсинга ссылки.
"""

Expand All @@ -26,14 +27,24 @@ class GetChatByLink(BaseConnection):
def __init__(self, bot: "Bot", link: str):
super().__init__()
self.bot = bot
self.link = findall(self.PATTERN_LINK, link)
self.link = self._normalize_link(link)

if not self.link:
if fullmatch(self.PATTERN_LINK, self.link) is None:
raise ValueError(f"link не соответствует {self.PATTERN_LINK!r}")

@staticmethod
def _normalize_link(link: str) -> str:
value = link.strip()
parsed = urlparse(value)

if parsed.scheme or parsed.netloc:
value = parsed.path.rstrip("/").rsplit("/", maxsplit=1)[-1]

return value

async def fetch(self) -> Chat:
"""
Выполняет GET-запрос для получения данных чата по ссылке.
Выполняет GET-запрос для получения данных канала по ссылке.

Returns:
Chat: Объект с информацией о чате.
Expand All @@ -43,7 +54,7 @@ async def fetch(self) -> Chat:

response = await super().request(
method=HTTPMethod.GET,
path=ApiPath.CHATS.value + "/" + self.link[-1],
path=ApiPath.CHATS.value + "/" + self.link,
model=Chat,
params=bot.params,
)
Expand Down
24 changes: 16 additions & 8 deletions maxapi/methods/get_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from ..enums.api_path import ApiPath
from ..enums.http_method import HTTPMethod
from ..types.message import Messages
from ..utils.time import to_ms

if TYPE_CHECKING:
from ..bot import Bot
Expand Down Expand Up @@ -33,11 +32,19 @@ def __init__(
message_ids: list[str] | None = None,
from_time: datetime | int | None = None,
to_time: datetime | int | None = None,
count: int = 50,
count: int | None = 50,
):
if count is not None and not (1 <= count <= 100):
raise ValueError("count не должен быть меньше 1 или больше 100")

has_chat_id = chat_id is not None
has_message_ids = bool(message_ids)
if has_chat_id == has_message_ids:
raise ValueError(
"Нужно передать ровно один из параметров: "
"chat_id или message_ids"
)

super().__init__()
self.bot = bot
self.chat_id = chat_id
Expand All @@ -61,25 +68,26 @@ async def fetch(self) -> Messages:

params = bot.params.copy()

if self.chat_id:
if self.chat_id is not None:
params["chat_id"] = self.chat_id

if self.message_ids:
params["message_ids"] = ",".join(self.message_ids)

if self.from_time:
if self.from_time is not None:
if isinstance(self.from_time, datetime):
params["from"] = to_ms(self.from_time)
params["from"] = int(self.from_time.timestamp())
else:
params["from"] = self.from_time

if self.to_time:
if self.to_time is not None:
if isinstance(self.to_time, datetime):
params["to"] = to_ms(self.to_time)
params["to"] = int(self.to_time.timestamp())
else:
params["to"] = self.to_time

params["count"] = self.count
if self.count is not None:
params["count"] = self.count

response = await super().request(
method=HTTPMethod.GET,
Expand Down
2 changes: 1 addition & 1 deletion maxapi/types/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,7 @@ async def history(
message_ids: list[str] | None = None,
from_time: datetime | int | None = None,
to_time: datetime | int | None = None,
count: int = 50,
count: int | None = 50,
) -> Messages:
"""Получить историю сообщений текущего чата."""

Expand Down
166 changes: 158 additions & 8 deletions tests/test_swagger_alignment.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, Mock, patch

import pytest
from maxapi.connection.base import BaseConnection
from maxapi.enums.add_chat_members_error_code import AddChatMembersErrorCode
from maxapi.enums.attachment import AttachmentType
from maxapi.enums.chat_permission import ChatPermission
from maxapi.enums.text_style import TextStyle
from maxapi.methods.get_chat_by_link import GetChatByLink
from maxapi.methods.get_messages import GetMessages
from maxapi.methods.types.added_members_chat import AddedMembersChat
from maxapi.types.attachments.attachment import ContactAttachmentPayload
from maxapi.types.attachments.image import PhotoAttachmentRequestPayload
from maxapi.types.attachments.video import Video
from maxapi.types.chats import ChatMember
from maxapi.types.message import MessageBody
from maxapi.types.users import ChatAdmin, User
from pydantic import ValidationError
Expand Down Expand Up @@ -53,15 +60,25 @@ def test_added_members_chat_error_code_enum_rejects_unknown_code():


def test_chat_permission_accepts_swagger_admin_values():
assert (
ChatPermission.POST_EDIT_DELETE_MESSAGE.value
== "post_edit_delete_message"
)
assert ChatPermission.EDIT_MESSAGE.value == "edit_message"
assert ChatPermission.DELETE_MESSAGE.value == "delete_message"
swagger_values = {
"read_all_messages",
"add_remove_members",
"add_admins",
"change_chat_info",
"pin_message",
"write",
"can_call",
"edit_link",
"post_edit_delete_message",
"edit_message",
"delete_message",
"edit",
"delete",
}

assert ChatPermission.EDIT.value == "edit"
assert ChatPermission.DELETE.value == "delete"
assert {ChatPermission(value).value for value in swagger_values} == (
swagger_values
)
assert ChatPermission.VIEW_STATS.value == "view_stats"


Expand All @@ -87,6 +104,139 @@ def test_user_and_chat_admin_keep_swagger_compat_fields():
assert admin.alias == "owner"


def test_chat_member_accepts_swagger_fields_with_nullable_permissions():
member = ChatMember.model_validate(
{
"user_id": 1,
"first_name": "Alice",
"username": "alice",
"is_bot": False,
"last_activity_time": 0,
"last_access_time": 10,
"is_owner": False,
"is_admin": True,
"join_time": 20,
"permissions": None,
"alias": "moderator",
}
)

assert member.last_access_time == 10
assert member.is_owner is False
assert member.is_admin is True
assert member.join_time == 20
assert member.permissions is None
assert member.alias == "moderator"


def test_get_messages_requires_chat_id_or_message_ids(bot):
with pytest.raises(ValueError, match="chat_id или message_ids"):
GetMessages(bot=bot)


def test_get_messages_rejects_chat_id_with_message_ids(bot):
with pytest.raises(ValueError, match="chat_id или message_ids"):
GetMessages(bot=bot, chat_id=1, message_ids=["mid-1"])


async def test_get_messages_sends_message_ids_as_comma_list(bot):
method = GetMessages(
bot=bot,
message_ids=["mid-1", "mid-2"],
)

with patch.object(
BaseConnection, "request", new=AsyncMock(return_value=Mock())
) as mocked_request:
await method.fetch()

params = mocked_request.call_args.kwargs["params"]
assert params["message_ids"] == "mid-1,mid-2"
assert "chat_id" not in params


async def test_get_messages_sends_datetime_bounds_as_unix_seconds(bot):
from_time = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
to_time = datetime(2026, 1, 3, 3, 4, 5, tzinfo=timezone.utc)
method = GetMessages(
bot=bot,
chat_id=1,
from_time=from_time,
to_time=to_time,
)

with patch.object(
BaseConnection, "request", new=AsyncMock(return_value=Mock())
) as mocked_request:
await method.fetch()

params = mocked_request.call_args.kwargs["params"]
assert params["from"] == int(from_time.timestamp())
assert params["to"] == int(to_time.timestamp())


async def test_get_messages_omits_none_count(bot):
method = GetMessages(bot=bot, chat_id=1, count=None)

with patch.object(
BaseConnection, "request", new=AsyncMock(return_value=Mock())
) as mocked_request:
await method.fetch()

params = mocked_request.call_args.kwargs["params"]
assert "count" not in params


@pytest.mark.parametrize(
("link", "expected_path"),
[
("channel", "/chats/channel"),
("@channel", "/chats/@channel"),
("https://max.ru/channel", "/chats/channel"),
],
)
async def test_get_chat_by_link_normalizes_public_link(
bot,
link,
expected_path,
):
method = GetChatByLink(bot=bot, link=link)

with patch.object(
BaseConnection, "request", new=AsyncMock(return_value=Mock())
) as mocked_request:
await method.fetch()

assert mocked_request.call_args.kwargs["path"] == expected_path


@pytest.mark.parametrize(
"link",
[
"",
"not a link",
"https://max.ru/",
"https://max.ru/channel/extra invalid",
],
)
def test_get_chat_by_link_rejects_invalid_link(bot, link):
with pytest.raises(ValueError, match="link не соответствует"):
GetChatByLink(bot=bot, link=link)


async def test_get_chat_by_link_keeps_valid_link_characters(bot):
method = GetChatByLink(bot=bot, link="channel-name_123")

with patch.object(
BaseConnection, "request", new=AsyncMock(return_value=Mock())
) as mocked_request:
await method.fetch()

assert mocked_request.call_args.kwargs["path"] == (
"/chats/channel-name_123"
)


def test_contact_payload_accepts_hash_and_nullable_vcf():
payload = ContactAttachmentPayload.model_validate(
{"vcf_info": None, "hash": "contact-hash", "max_info": None}
Expand Down
Loading