From 4d5a7bff5d0ca5c81b9d6e02a10666569efa45ef Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:04:11 +0300 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=D1=83=D1=82=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=81=D1=81=D1=8B=D0=BB=D0=BE=D0=BA=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D1=8F=20*=20bu?= =?UTF-8?q?ild=5Fmessage=5Flink=20*=20=D0=9A=D0=BE=D0=BD=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=82=D0=B0=D1=86=D0=B8:=20=20=20=20=20mid=5Fto=5Fchatid=5Fseq?= =?UTF-8?q?=20=20=20=20=20chatid=5Fseq=5Fto=5Fmid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add: Валидация функций add: link_to_chatid_seq - обратное преобразование * Валидация mid перенесена в функцию mid_to_chatid_seq add: Тесты на реальных данных проходят успешно. --- maxapi/utils/__init__.py | 13 + maxapi/utils/message_link.py | 129 ++++++++++ tests/test_utils/test_message_link.py | 343 ++++++++++++++++++++++++++ 3 files changed, 485 insertions(+) create mode 100644 maxapi/utils/message_link.py create mode 100644 tests/test_utils/test_message_link.py diff --git a/maxapi/utils/__init__.py b/maxapi/utils/__init__.py index e69de29..7cbaba8 100644 --- a/maxapi/utils/__init__.py +++ b/maxapi/utils/__init__.py @@ -0,0 +1,13 @@ +from .message_link import ( + mid_to_chatid_seq, + chatid_seq_to_mid, + build_message_link, + link_to_chatid_seq, +) + +__all__ = [ + "mid_to_chatid_seq", + "chatid_seq_to_mid", + "build_message_link", + "link_to_chatid_seq", +] diff --git a/maxapi/utils/message_link.py b/maxapi/utils/message_link.py new file mode 100644 index 0000000..9ec3097 --- /dev/null +++ b/maxapi/utils/message_link.py @@ -0,0 +1,129 @@ +import re +import base64 +from urllib.parse import urlparse + + +def mid_to_chatid_seq(mid: str) -> tuple[int, int]: + """ + Декодирует строку mid в chat_id и seq. + Формат mid: 'mid.' + 16 hex-символов (chat_id) + 16 hex-символов (seq) + """ + if not isinstance(mid, str): + raise TypeError('mid должен быть строкой') + + if not re.fullmatch(r'mid\.[0-9a-fA-F]{32}', mid): + raise ValueError('mid должен быть в формате "mid." + 32 hex-символа') + + hex_part = mid[4:] + + # Первые 16 символов — chat_id. MAX хранит его как signed 64-bit, + # но в hex он представлен как unsigned. Конвертируем обратно в signed. + chat_id_unsigned = int(hex_part[:16], 16) + chat_id = chat_id_unsigned - (1 << 64) if chat_id_unsigned >= (1 << 63) else chat_id_unsigned + + # Последние 16 символов — seq. Всегда положительное 64-bit число. + seq = int(hex_part[16:], 16) + + return chat_id, seq + + +def chatid_seq_to_mid(chat_id: int, seq: int) -> str: + """ + Создаёт валидную строку mid из chat_id и seq. + """ + if not isinstance(chat_id, int): + raise TypeError('chat_id должен быть целым числом') + if not isinstance(seq, int): + raise TypeError('seq должен быть целым числом') + + if chat_id < -(1 << 63) or chat_id >= (1 << 63): + raise ValueError('chat_id выходит за пределы знакового 64-битного диапазона') + if seq < 0 or seq >= (1 << 64): + raise ValueError('seq выходит за пределы беззнакового 64-битного диапазона') + + # Битовая маска гарантирует корректное hex-представление для signed int + # (отрицательные числа автоматически преобразуются в two's complement) + chat_id_hex = f"{chat_id & 0xFFFFFFFFFFFFFFFF:016x}" + seq_hex = f"{seq:016x}" + + return f"mid.{chat_id_hex}{seq_hex}" + + +def build_message_link(mid: str) -> str: + """ + Генерирует прямую ссылку на сообщение в интерфейсе MAX. + Формат: https://max.ru/c/{chat_id}/{urlsafe_base64(seq_без_padding)} + """ + + chat_id, seq = mid_to_chatid_seq(mid) # Валидация происходит здесь + + # 1. Преобразуем seq в 8 байт (big-endian) + seq_bytes = seq.to_bytes(8, byteorder="big") + # 2. Кодируем в URL-safe Base64 и убираем символы дополнения '=' + seq_b64 = base64.urlsafe_b64encode(seq_bytes).decode("ascii").rstrip("=") + + return f"https://max.ru/c/{chat_id}/{seq_b64}" + + +def link_to_chatid_seq(link: str) -> tuple[int, int]: + """ + Парсит ссылку на сообщение в интерфейсе MAX и извлекает chat_id и seq. + Формат ссылки: https://max.ru/c/{chat_id}/{urlsafe_base64(seq_без_padding)} + + Не обрабатываются ссылки на публичные каналы вида https://max.ru/{channe_name}/{urlsafe_base64} + Только приватные чаты и группы + + Returns: + tuple[int, int]: (chat_id, seq) + """ + # Валидация типа + if not isinstance(link, str): + raise TypeError('link должен быть строкой') + + parsed = urlparse(link) + + # Валидация схемы и домена + if parsed.scheme != 'https': + raise ValueError('Ссылка должна использовать https схему') + if parsed.netloc != 'max.ru': + raise ValueError('Ссылка должна указывать на домен max.ru') + + # Валидация пути: /c/{chat_id}/{seq_b64} + path_parts = parsed.path.strip('/').split('/') + if len(path_parts) != 3 or path_parts[0] != 'c': + raise ValueError('Неверный формат пути в ссылке. Ожидается: /c/{chat_id}/{seq_b64}') + + # Извлечение и валидация chat_id + try: + chat_id = int(path_parts[1]) + except ValueError: + raise ValueError('chat_id в ссылке должен быть целым числом') + + if chat_id < -(1 << 63) or chat_id >= (1 << 63): + raise ValueError('chat_id выходит за пределы знакового 64-битного диапазона') + + # Извлечение seq_b64 + seq_b64 = path_parts[2] + + if not seq_b64 or not re.fullmatch(r'[A-Za-z0-9_-]+', seq_b64): + raise ValueError('seq в ссылке должен быть в url-safe base64 формате') + + # Добавляем паддинг для корректного декодирования base64 + # Длина base64 должна быть кратна 4 + padding_needed = (4 - len(seq_b64) % 4) % 4 + seq_b64_padded = seq_b64 + '=' * padding_needed + + try: + # Декодируем из url-safe base64 + seq_bytes = base64.urlsafe_b64decode(seq_b64_padded) + except Exception as e: + raise ValueError(f'Ошибка декодирования base64: {e}') + + # Валидация длины: seq должен быть 8 байт (64 бита) + if len(seq_bytes) != 8: + raise ValueError('seq должен быть представлен 8 байтами (64 бита)') + + # Конвертируем байты в int (big-endian, unsigned) + seq = int.from_bytes(seq_bytes, byteorder='big') + + return chat_id, seq diff --git a/tests/test_utils/test_message_link.py b/tests/test_utils/test_message_link.py new file mode 100644 index 0000000..33adec0 --- /dev/null +++ b/tests/test_utils/test_message_link.py @@ -0,0 +1,343 @@ +import re + +import pytest +import base64 +from maxapi.utils.message_link import ( + mid_to_chatid_seq, + chatid_seq_to_mid, + build_message_link, + link_to_chatid_seq, +) + +# ============================================================================= +# Реальные тестовые данные из продакшена +# ============================================================================= +REAL_TEST_CASES = [ + # Положительный chat_id + { + "chat_id": 191387420, + "mid": "mid.000000000b68571c019d5eac630d58ce", + "seq": 116353259870705870, + "link": None, # ссылка не предоставлена, сгенерируем в тесте + }, + { + "chat_id": 191387420, + "mid": "mid.000000000b68571c019d5eaa646c000f", + "seq": 116353251303751695, + "link": None, + }, + # Отрицательный chat_id с готовой ссылкой + { + "chat_id": -73455901853123, + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + "link": "https://max.ru/c/-73455901853123/AZ2H-TzaAOc", + }, + { + "chat_id": -71955698945289, + "mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554, + "link": "https://max.ru/c/-71955698945289/AZ113QDTS3o", + }, +] + + +# ============================================================================= +# Тесты на реальных данных +# ============================================================================= +class TestRealData: + """Тесты на реальных примерах из продакшена""" + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_mid_to_chatid_seq_real(self, case): + """Декодирование mid -> (chat_id, seq)""" + chat_id, seq = mid_to_chatid_seq(case["mid"]) + assert chat_id == case["chat_id"] + assert seq == case["seq"] + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_chatid_seq_to_mid_real(self, case): + """Кодирование (chat_id, seq) -> mid""" + mid = chatid_seq_to_mid(case["chat_id"], case["seq"]) + assert mid == case["mid"] + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_roundtrip_mid(self, case): + """Круговое преобразование: mid -> (chat_id, seq) -> mid""" + chat_id, seq = mid_to_chatid_seq(case["mid"]) + mid_restored = chatid_seq_to_mid(chat_id, seq) + assert mid_restored == case["mid"] + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_roundtrip_chatid_seq(self, case): + """Круговое преобразование: (chat_id, seq) -> mid -> (chat_id, seq)""" + mid = chatid_seq_to_mid(case["chat_id"], case["seq"]) + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == case["chat_id"] + assert seq_restored == case["seq"] + + @pytest.mark.parametrize("case", [c for c in REAL_TEST_CASES if c["link"]]) + def test_build_message_link_real(self, case): + """Генерация ссылки из mid""" + link = build_message_link(case["mid"]) + assert link == case["link"] + + @pytest.mark.parametrize("case", [c for c in REAL_TEST_CASES if c["link"]]) + def test_link_to_chatid_seq_real(self, case): + """Парсинг ссылки -> (chat_id, seq)""" + chat_id, seq = link_to_chatid_seq(case["link"]) + assert chat_id == case["chat_id"] + assert seq == case["seq"] + + @pytest.mark.parametrize("case", [c for c in REAL_TEST_CASES if c["link"]]) + def test_roundtrip_link(self, case): + """Круговое преобразование: link -> (chat_id, seq) -> link""" + chat_id, seq = link_to_chatid_seq(case["link"]) + mid = chatid_seq_to_mid(chat_id, seq) + link_restored = build_message_link(mid) + assert link_restored == case["link"] + + @pytest.mark.parametrize("case", REAL_TEST_CASES) + def test_full_pipeline(self, case): + """Полный пайплайн: (chat_id, seq) -> mid -> link -> (chat_id, seq)""" + mid = chatid_seq_to_mid(case["chat_id"], case["seq"]) + link = build_message_link(mid) + chat_id_parsed, seq_parsed = link_to_chatid_seq(link) + + assert chat_id_parsed == case["chat_id"] + assert seq_parsed == case["seq"] + + # Дополнительно: если была исходная ссылка, проверяем совпадение + if case["link"]: + assert link == case["link"] + + +# ============================================================================= +# Тесты на граничные значения +# ============================================================================= +class TestEdgeCases: + """Тесты на граничные значения 64-битных чисел""" + + def test_chat_id_min_signed(self): + """Минимальное значение signed int64: -2^63""" + chat_id = -(1 << 63) + seq = 0 + mid = chatid_seq_to_mid(chat_id, seq) + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == chat_id + assert seq_restored == seq + + def test_chat_id_max_signed(self): + """Максимальное значение signed int64: 2^63 - 1""" + chat_id = (1 << 63) - 1 + seq = (1 << 64) - 1 # max unsigned seq + mid = chatid_seq_to_mid(chat_id, seq) + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == chat_id + assert seq_restored == seq + + def test_seq_zero(self): + """seq = 0""" + chat_id = 0 + seq = 0 + mid = chatid_seq_to_mid(chat_id, seq) + assert mid == "mid.00000000000000000000000000000000" + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == 0 + assert seq_restored == 0 + + def test_seq_max_unsigned(self): + """seq = 2^64 - 1 (максимальное unsigned 64-bit)""" + chat_id = 0 + seq = (1 << 64) - 1 + mid = chatid_seq_to_mid(chat_id, seq) + assert mid == "mid.0000000000000000ffffffffffffffff" + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert seq_restored == seq + + def test_negative_chat_id_hex_representation(self): + """Проверка two's complement для отрицательных chat_id""" + # -1 в two's complement 64-bit = 0xFFFFFFFFFFFFFFFF + chat_id = -1 + seq = 0 + mid = chatid_seq_to_mid(chat_id, seq) + assert mid == "mid.ffffffffffffffff0000000000000000" + chat_id_restored, _ = mid_to_chatid_seq(mid) + assert chat_id_restored == -1 + + +# ============================================================================= +# Тесты на ошибки валидации +# ============================================================================= +class TestValidationErrors: + """Тесты на корректную обработку невалидных входных данных""" + + # ------------------ mid_to_chatid_seq ------------------ + def test_mid_wrong_type(self): + with pytest.raises(TypeError, match="mid должен быть строкой"): + mid_to_chatid_seq(12345) + + def test_mid_missing_prefix(self): + with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + mid_to_chatid_seq("000000000b68571c019d5eac630d58ce") + + def test_mid_wrong_length_short(self): + with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + mid_to_chatid_seq("mid.000000000b68571c") + + def test_mid_wrong_length_long(self): + with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + mid_to_chatid_seq("mid.000000000b68571c019d5eac630d58ce00") + + def test_mid_invalid_hex_chars(self): + with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + mid_to_chatid_seq("mid.000000000b68571g019d5eac630d58ce") # 'g' не hex + + # ------------------ chatid_seq_to_mid ------------------ + def test_chatid_wrong_type(self): + with pytest.raises(TypeError, match="chat_id должен быть целым числом"): + chatid_seq_to_mid("123", 100) + + def test_seq_wrong_type(self): + with pytest.raises(TypeError, match="seq должен быть целым числом"): + chatid_seq_to_mid(123, "100") + + def test_chat_id_out_of_range_low(self): + with pytest.raises(ValueError, match="chat_id выходит за пределы"): + chatid_seq_to_mid(-(1 << 63) - 1, 0) + + def test_chat_id_out_of_range_high(self): + with pytest.raises(ValueError, match="chat_id выходит за пределы"): + chatid_seq_to_mid(1 << 63, 0) + + def test_seq_negative(self): + with pytest.raises(ValueError, match="seq выходит за пределы"): + chatid_seq_to_mid(0, -1) + + def test_seq_out_of_range(self): + with pytest.raises(ValueError, match="seq выходит за пределы"): + chatid_seq_to_mid(0, 1 << 64) + + # ------------------ build_message_link ------------------ + def test_link_wrong_mid_prefix(self): + with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + build_message_link("invalid.000000000b68571c019d5eac630d58ce") + + def test_link_wrong_mid_format(self): + with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + build_message_link("mid.000000000b68571g019d5eac630d58ce") + + def test_link_wrong_mid_length(self): + with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + build_message_link("mid.0000") + + # ------------------ link_to_chatid_seq ------------------ + def test_link_wrong_type(self): + with pytest.raises(TypeError, match="link должен быть строкой"): + link_to_chatid_seq(12345) + + def test_link_wrong_scheme(self): + with pytest.raises(ValueError, match="Ссылка должна использовать https схему"): + link_to_chatid_seq("ftp://max.ru/c/123/ABC") + + def test_link_wrong_domain(self): + with pytest.raises(ValueError, match="Ссылка должна указывать на домен max.ru"): + link_to_chatid_seq("https://example.com/c/123/ABC") + + def test_link_wrong_path_format(self): + with pytest.raises(ValueError, match="Неверный формат пути"): + link_to_chatid_seq("https://max.ru/chat/123/ABC") + + def test_link_wrong_path_parts_count(self): + with pytest.raises(ValueError, match="Неверный формат пути"): + link_to_chatid_seq("https://max.ru/c/123") # нет seq + + def test_link_chat_id_not_int(self): + with pytest.raises(ValueError, match="chat_id в ссылке должен быть целым числом"): + link_to_chatid_seq("https://max.ru/c/abc/ABC") + + def test_link_invalid_base64_chars(self): + with pytest.raises(ValueError, match="должен быть в url-safe base64 формате"): + link_to_chatid_seq("https://max.ru/c/123/ABC@#$") + + def test_link_base64_decode_error(self): + # Некорректный base64 (невозможно декодировать) + with pytest.raises(ValueError, match="seq в ссылке должен быть в url-safe base64 формате"): + link_to_chatid_seq("https://max.ru/c/123/!!!") + + def test_link_seq_wrong_byte_length(self): + # Base64, который декодируется не в 8 байт + # "AQ" -> 1 байт после декодирования + with pytest.raises(ValueError, match="seq должен быть представлен 8 байтами"): + link_to_chatid_seq("https://max.ru/c/123/AQ") + + +# ============================================================================= +# Тесты на генерацию ссылок (build_message_link) +# ============================================================================= +class TestBuildMessageLink: + """Тесты функции build_message_link""" + + def test_link_format_positive_chat(self): + """Проверка формата ссылки для положительного chat_id""" + mid = "mid.000000000b68571c019d5eac630d58ce" + link = build_message_link(mid) + assert link.startswith("https://max.ru/c/") + assert "191387420" in link # chat_id в ссылке + + def test_link_format_negative_chat(self): + """Проверка формата ссылки для отрицательного chat_id""" + mid = "mid.ffffbd3137103a3d019d87f93cda00e7" + link = build_message_link(mid) + assert link.startswith("https://max.ru/c/-") + assert "-73455901853123" in link + + def test_link_seq_base64_urlsafe_no_padding(self): + """seq в ссылке должен быть url-safe base64 без паддинга""" + mid = "mid.00000000000000000000000000000001" # seq = 1 + link = build_message_link(mid) + seq_part = link.split("/")[-1] + assert "=" not in seq_part # нет паддинга + assert all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" for c in seq_part) + + def test_link_seq_decoding_consistency(self): + """Декодирование seq из ссылки должно давать исходное значение""" + test_cases = [ + (0, "AAAAAAAAAAA"), # 0 -> 8 нулевых байт + (1, "AAAAAAAAAAE"), # 1 -> 7 нулей + 0x01 + (255, "AAAAAAAAAAP"), # 255 -> 7 нулей + 0xFF + (116353259870705870, "AZ1erGMNWM4"), # из реальных данных + ] + for seq, expected_b64_prefix in test_cases: + mid = chatid_seq_to_mid(12345, seq) + link = build_message_link(mid) + seq_from_link = link.split("/")[-1] + # Проверяем, что декодирование даёт исходный seq + padding = (4 - len(seq_from_link) % 4) % 4 + decoded = base64.urlsafe_b64decode(seq_from_link + "=" * padding) + assert int.from_bytes(decoded, "big") == seq + + +# ============================================================================= +# Параметризованные тесты для покрытия различных сценариев +# ============================================================================= +@pytest.mark.parametrize("chat_id,seq", [ + (0, 0), + (1, 1), + (-1, 1), + (999999999, 999999999), + (-999999999, 999999999), + (2**62, 2**63), # большие значения в пределах диапазона + (-(2**62), 2**63 - 1), +]) +def test_parametrized_roundtrip(chat_id: int, seq: int): + """Параметризованный тест кругового преобразования""" + mid = chatid_seq_to_mid(chat_id, seq) + chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + assert chat_id_restored == chat_id + assert seq_restored == seq + + link = build_message_link(mid) + chat_id_from_link, seq_from_link = link_to_chatid_seq(link) + assert chat_id_from_link == chat_id + assert seq_from_link == seq From a7fa2729bbb60a3af455eb7b8971095a10f7a957 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:08:14 +0300 Subject: [PATCH 2/6] add: property message.url_link add: docstrings --- maxapi/types/message.py | 7 +++ maxapi/utils/message_link.py | 75 +++++++++++++++++++++------ tests/test_utils/test_message_link.py | 19 +++++-- 3 files changed, 81 insertions(+), 20 deletions(-) diff --git a/maxapi/types/message.py b/maxapi/types/message.py index 9dd8f37..38aea03 100644 --- a/maxapi/types/message.py +++ b/maxapi/types/message.py @@ -23,6 +23,7 @@ UserMention, ) from .users import User +from ..utils.message_link import build_message_link if TYPE_CHECKING: from ..bot import Bot @@ -631,6 +632,12 @@ async def unpin(self) -> DeletedPinMessage: chat_id=self.recipient.chat_id, ) + @property + def url_link(self): + """ + Прямая ссылка на сообщение в интерфейсе MAX + """ + return build_message_link(self.mid) class Messages(BaseModel): """ diff --git a/maxapi/utils/message_link.py b/maxapi/utils/message_link.py index 9ec3097..4562461 100644 --- a/maxapi/utils/message_link.py +++ b/maxapi/utils/message_link.py @@ -1,12 +1,30 @@ import re import base64 from urllib.parse import urlparse +import binascii +def _validate_chat_id(chat_id: int) -> None: + """Проверяет, что chat_id в диапазоне signed int64.""" + if chat_id < -(1 << 63) or chat_id >= (1 << 63): + raise ValueError('chat_id выходит за пределы знакового 64-битного диапазона') def mid_to_chatid_seq(mid: str) -> tuple[int, int]: """ - Декодирует строку mid в chat_id и seq. - Формат mid: 'mid.' + 16 hex-символов (chat_id) + 16 hex-символов (seq) + Декодирует строку mid в пару (chat_id, seq). + + Формат mid: 'mid.' + 32 hex-символа, где: + - первые 16 символов: chat_id (signed int64, stored as unsigned hex) + - последние 16 символов: seq (unsigned int64) + + Args: + mid (str): Строка формата "mid.{32 hex символа}" + + Returns: + tuple[int, int]: Кортеж (chat_id, seq), где chat_id — signed, seq — unsigned + + Raises: + TypeError: Если mid не является строкой + ValueError: Если mid не соответствует ожидаемому формату """ if not isinstance(mid, str): raise TypeError('mid должен быть строкой') @@ -30,14 +48,26 @@ def mid_to_chatid_seq(mid: str) -> tuple[int, int]: def chatid_seq_to_mid(chat_id: int, seq: int) -> str: """ Создаёт валидную строку mid из chat_id и seq. + + Формат результата: 'mid.' + 32 hex-символа (16 для chat_id + 16 для seq) + + Args: + chat_id (int): ID чата (signed int64, диапазон: -(2**63) .. 2**63-1) + seq (int): Порядковый номер сообщения (unsigned int64, диапазон: 0 .. 2**64-1) + + Returns: + str: Строка mid формата "mid.{32 hex символа}" + + Raises: + TypeError: Если chat_id или seq не являются int + ValueError: Если chat_id или seq выходят за допустимые диапазоны """ if not isinstance(chat_id, int): raise TypeError('chat_id должен быть целым числом') if not isinstance(seq, int): raise TypeError('seq должен быть целым числом') - if chat_id < -(1 << 63) or chat_id >= (1 << 63): - raise ValueError('chat_id выходит за пределы знакового 64-битного диапазона') + _validate_chat_id(chat_id) if seq < 0 or seq >= (1 << 64): raise ValueError('seq выходит за пределы беззнакового 64-битного диапазона') @@ -52,7 +82,17 @@ def chatid_seq_to_mid(chat_id: int, seq: int) -> str: def build_message_link(mid: str) -> str: """ Генерирует прямую ссылку на сообщение в интерфейсе MAX. - Формат: https://max.ru/c/{chat_id}/{urlsafe_base64(seq_без_padding)} + + Args: + mid (str): Значение из message.body.mid + + Returns: + str: URL ссылка на сообщение в интерфейсе пользовательского приложения MAX. + Формат: https://max.ru/c/{chat_id}/{urlsafe_base64(seq_без_padding)} + + Raises: + TypeError: Если mid не строка + ValueError: Если mid не соответствует формату "mid." + 32 hex-символа """ chat_id, seq = mid_to_chatid_seq(mid) # Валидация происходит здесь @@ -68,13 +108,19 @@ def build_message_link(mid: str) -> str: def link_to_chatid_seq(link: str) -> tuple[int, int]: """ Парсит ссылку на сообщение в интерфейсе MAX и извлекает chat_id и seq. - Формат ссылки: https://max.ru/c/{chat_id}/{urlsafe_base64(seq_без_padding)} - - Не обрабатываются ссылки на публичные каналы вида https://max.ru/{channe_name}/{urlsafe_base64} - Только приватные чаты и группы + Не обрабатываются ссылки на публичные каналы вида https://max.ru/{channel_name}/{urlsafe_base64} + Только приватные чаты и группы. + + Args: + link (str): Ссылка формата https://max.ru/c/{chat_id}/{seq_b64} + Returns: tuple[int, int]: (chat_id, seq) + + Raises: + TypeError: Если link не строка + ValueError: Если ссылка невалидна или ссылка на канал (chat_id не число) """ # Валидация типа if not isinstance(link, str): @@ -96,11 +142,10 @@ def link_to_chatid_seq(link: str) -> tuple[int, int]: # Извлечение и валидация chat_id try: chat_id = int(path_parts[1]) - except ValueError: - raise ValueError('chat_id в ссылке должен быть целым числом') + except ValueError as e: + raise ValueError('chat_id в ссылке должен быть целым числом') from e - if chat_id < -(1 << 63) or chat_id >= (1 << 63): - raise ValueError('chat_id выходит за пределы знакового 64-битного диапазона') + _validate_chat_id(chat_id) # Извлечение seq_b64 seq_b64 = path_parts[2] @@ -116,8 +161,8 @@ def link_to_chatid_seq(link: str) -> tuple[int, int]: try: # Декодируем из url-safe base64 seq_bytes = base64.urlsafe_b64decode(seq_b64_padded) - except Exception as e: - raise ValueError(f'Ошибка декодирования base64: {e}') + except (binascii.Error, ValueError) as e: + raise ValueError(f'Ошибка декодирования base64: {e}') from e # Валидация длины: seq должен быть 8 байт (64 бита) if len(seq_bytes) != 8: diff --git a/tests/test_utils/test_message_link.py b/tests/test_utils/test_message_link.py index 33adec0..8846016 100644 --- a/tests/test_utils/test_message_link.py +++ b/tests/test_utils/test_message_link.py @@ -300,19 +300,28 @@ def test_link_seq_base64_urlsafe_no_padding(self): assert "=" not in seq_part # нет паддинга assert all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" for c in seq_part) + def test_link_seq_decoding_consistency(self): """Декодирование seq из ссылки должно давать исходное значение""" + + def _seq_to_b64(seq: int) -> str: + """Вспомогательная функция: seq → URL-safe base64 без паддинга.""" + return base64.urlsafe_b64encode(seq.to_bytes(8, "big")).decode().rstrip("=") + test_cases = [ - (0, "AAAAAAAAAAA"), # 0 -> 8 нулевых байт - (1, "AAAAAAAAAAE"), # 1 -> 7 нулей + 0x01 - (255, "AAAAAAAAAAP"), # 255 -> 7 нулей + 0xFF - (116353259870705870, "AZ1erGMNWM4"), # из реальных данных + 0, # Минимум (все биты 0) + 1, # Единица (проверка младших битов) + 255, # Граница 1 байта (0xFF) + 2**64 - 1, # Максимум unsigned int64 (все биты 1) + 116353259870705870, # Реальное значение из продакшена ] - for seq, expected_b64_prefix in test_cases: + for seq in test_cases: + expected_b64 = _seq_to_b64(seq) mid = chatid_seq_to_mid(12345, seq) link = build_message_link(mid) seq_from_link = link.split("/")[-1] # Проверяем, что декодирование даёт исходный seq + assert seq_from_link == expected_b64 padding = (4 - len(seq_from_link) % 4) % 4 decoded = base64.urlsafe_b64decode(seq_from_link + "=" * padding) assert int.from_bytes(decoded, "big") == seq From 52d39b383f7bf1e76ee9a54744b2e1a361a38ba2 Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 23 Apr 2026 15:08:15 +0300 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=D0=9E=D0=B1=D1=8A=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=91=D0=BD=D0=BD=D0=BE=D0=B5=20=D1=81=D0=B2=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=BE=20Message.url=20*=20API=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=D0=BE=D0=B2=20*?= =?UTF-8?q?=20=D0=92=D1=8B=D1=87=D0=B8=D1=81=D0=BB=D0=B5=D0=BD=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D1=85=20=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=BD=D1=8B=D1=85=20=D1=87=D0=B0=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=20*=20=D0=91=D0=BE=D0=BB=D0=B5=D0=B5=20=D1=80=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D1=85=20?= =?UTF-8?q?add:=20tests=20message.url=20refactor:=20ruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maxapi/types/message.py | 32 ++++- maxapi/utils/__init__.py | 8 +- maxapi/utils/message_link.py | 119 ++++++++-------- tests/test_message_url.py | 196 ++++++++++++++++++++++++++ tests/test_utils/test_message_link.py | 99 ++++++++----- 5 files changed, 350 insertions(+), 104 deletions(-) create mode 100644 tests/test_message_url.py diff --git a/maxapi/types/message.py b/maxapi/types/message.py index 38aea03..dd10810 100644 --- a/maxapi/types/message.py +++ b/maxapi/types/message.py @@ -22,8 +22,8 @@ Underline, UserMention, ) -from .users import User from ..utils.message_link import build_message_link +from .users import User if TYPE_CHECKING: from ..bot import Bot @@ -321,17 +321,26 @@ class Message( Текст + вложения. Может быть null, если сообщение содержит только пересланное сообщение stat (Optional[MessageStat]): Статистика сообщения. Может быть None. - url (Optional[str]): URL сообщения. Может быть None. + url_api (Optional[str]): URL сообщения из ответа API. + Публичная ссылка на пост в канале. + Отсутствует для диалогов и групповых чатов + url (Optional[str]): Генерируемое свойсто URL сообщения. + Дополняет ответ API для приватных чатов и групп + Может быть None в случае отсутвия body. bot (Optional[Bot]): Объект бота, исключается из сериализации. """ - + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) sender: User | None = None recipient: Recipient timestamp: int link: LinkedMessage | None = None body: MessageBody | None = None stat: MessageStat | None = None - url: str | None = None + url_api: str | None = Field( + # Поле для хранения сырого url из ответа API + alias="url", + default=None, + ) bot: Any | None = Field( # pyright: ignore[reportRedeclaration] default=None, exclude=True ) @@ -633,11 +642,22 @@ async def unpin(self) -> DeletedPinMessage: ) @property - def url_link(self): + def url(self) -> str | None: """ Прямая ссылка на сообщение в интерфейсе MAX + + Returns: + str: Ссылка на сообщение в формате + - Для диалогов и групповых чатов: https://max.ru/c/{chat_id}/{seq_b64} + - Постов в канале: https://max.ru/{channel_name}/{seq_b64} + None: Если объект Message не содержит в себе body """ - return build_message_link(self.mid) + if self.url_api: + return self.url_api + elif self.body: + return build_message_link(self.body.mid) + return None + class Messages(BaseModel): """ diff --git a/maxapi/utils/__init__.py b/maxapi/utils/__init__.py index 7cbaba8..015988c 100644 --- a/maxapi/utils/__init__.py +++ b/maxapi/utils/__init__.py @@ -1,13 +1,13 @@ from .message_link import ( - mid_to_chatid_seq, - chatid_seq_to_mid, build_message_link, + chatid_seq_to_mid, link_to_chatid_seq, + mid_to_chatid_seq, ) __all__ = [ - "mid_to_chatid_seq", - "chatid_seq_to_mid", "build_message_link", + "chatid_seq_to_mid", "link_to_chatid_seq", + "mid_to_chatid_seq", ] diff --git a/maxapi/utils/message_link.py b/maxapi/utils/message_link.py index 4562461..7961682 100644 --- a/maxapi/utils/message_link.py +++ b/maxapi/utils/message_link.py @@ -1,18 +1,20 @@ -import re import base64 -from urllib.parse import urlparse import binascii +import re +from urllib.parse import urlparse + def _validate_chat_id(chat_id: int) -> None: """Проверяет, что chat_id в диапазоне signed int64.""" if chat_id < -(1 << 63) or chat_id >= (1 << 63): - raise ValueError('chat_id выходит за пределы знакового 64-битного диапазона') + raise ValueError( + "chat_id выходит за пределы знакового 64-битного диапазона") def mid_to_chatid_seq(mid: str) -> tuple[int, int]: """ Декодирует строку mid в пару (chat_id, seq). - Формат mid: 'mid.' + 32 hex-символа, где: + Формат mid: "mid." + 32 hex-символа, где: - первые 16 символов: chat_id (signed int64, stored as unsigned hex) - последние 16 символов: seq (unsigned int64) @@ -20,24 +22,28 @@ def mid_to_chatid_seq(mid: str) -> tuple[int, int]: mid (str): Строка формата "mid.{32 hex символа}" Returns: - tuple[int, int]: Кортеж (chat_id, seq), где chat_id — signed, seq — unsigned + tuple[int, int]: Кортеж (chat_id, seq), + где chat_id — signed, seq — unsigned Raises: TypeError: Если mid не является строкой ValueError: Если mid не соответствует ожидаемому формату """ if not isinstance(mid, str): - raise TypeError('mid должен быть строкой') + raise TypeError("mid должен быть строкой") - if not re.fullmatch(r'mid\.[0-9a-fA-F]{32}', mid): + if not re.fullmatch(r"mid\.[0-9a-fA-F]{32}", mid): raise ValueError('mid должен быть в формате "mid." + 32 hex-символа') - + hex_part = mid[4:] - + # Первые 16 символов — chat_id. MAX хранит его как signed 64-bit, # но в hex он представлен как unsigned. Конвертируем обратно в signed. chat_id_unsigned = int(hex_part[:16], 16) - chat_id = chat_id_unsigned - (1 << 64) if chat_id_unsigned >= (1 << 63) else chat_id_unsigned + if chat_id_unsigned >= (1 << 63): + chat_id = chat_id_unsigned - (1 << 64) + else: + chat_id = chat_id_unsigned # Последние 16 символов — seq. Всегда положительное 64-bit число. seq = int(hex_part[16:], 16) @@ -49,11 +55,12 @@ def chatid_seq_to_mid(chat_id: int, seq: int) -> str: """ Создаёт валидную строку mid из chat_id и seq. - Формат результата: 'mid.' + 32 hex-символа (16 для chat_id + 16 для seq) + Формат результата: "mid." + 32 hex-символа (16 для chat_id + 16 для seq) Args: chat_id (int): ID чата (signed int64, диапазон: -(2**63) .. 2**63-1) - seq (int): Порядковый номер сообщения (unsigned int64, диапазон: 0 .. 2**64-1) + seq (int): Порядковый номер сообщения + (unsigned int64, диапазон: 0..2**64-1) Returns: str: Строка mid формата "mid.{32 hex символа}" @@ -63,19 +70,20 @@ def chatid_seq_to_mid(chat_id: int, seq: int) -> str: ValueError: Если chat_id или seq выходят за допустимые диапазоны """ if not isinstance(chat_id, int): - raise TypeError('chat_id должен быть целым числом') + raise TypeError("chat_id должен быть целым числом") if not isinstance(seq, int): - raise TypeError('seq должен быть целым числом') + raise TypeError("seq должен быть целым числом") _validate_chat_id(chat_id) if seq < 0 or seq >= (1 << 64): - raise ValueError('seq выходит за пределы беззнакового 64-битного диапазона') - + raise ValueError( + "seq выходит за пределы беззнакового 64-битного диапазона") + # Битовая маска гарантирует корректное hex-представление для signed int - # (отрицательные числа автоматически преобразуются в two's complement) + # (отрицательные числа автоматически преобразуются в two"s complement) chat_id_hex = f"{chat_id & 0xFFFFFFFFFFFFFFFF:016x}" seq_hex = f"{seq:016x}" - + return f"mid.{chat_id_hex}{seq_hex}" @@ -85,23 +93,23 @@ def build_message_link(mid: str) -> str: Args: mid (str): Значение из message.body.mid - + Returns: - str: URL ссылка на сообщение в интерфейсе пользовательского приложения MAX. + str: URL ссылка на сообщение в интерфейсе приложения MAX. Формат: https://max.ru/c/{chat_id}/{urlsafe_base64(seq_без_padding)} - + Raises: TypeError: Если mid не строка ValueError: Если mid не соответствует формату "mid." + 32 hex-символа """ chat_id, seq = mid_to_chatid_seq(mid) # Валидация происходит здесь - + # 1. Преобразуем seq в 8 байт (big-endian) seq_bytes = seq.to_bytes(8, byteorder="big") - # 2. Кодируем в URL-safe Base64 и убираем символы дополнения '=' + # 2. Кодируем в URL-safe Base64 и убираем символы дополнения "=" seq_b64 = base64.urlsafe_b64encode(seq_bytes).decode("ascii").rstrip("=") - + return f"https://max.ru/c/{chat_id}/{seq_b64}" @@ -111,64 +119,65 @@ def link_to_chatid_seq(link: str) -> tuple[int, int]: Не обрабатываются ссылки на публичные каналы вида https://max.ru/{channel_name}/{urlsafe_base64} Только приватные чаты и группы. - + Args: link (str): Ссылка формата https://max.ru/c/{chat_id}/{seq_b64} - + Returns: tuple[int, int]: (chat_id, seq) - + Raises: TypeError: Если link не строка - ValueError: Если ссылка невалидна или ссылка на канал (chat_id не число) + ValueError: Если ссылка невалидна / ссылка на канал (chat_id не число) """ # Валидация типа if not isinstance(link, str): - raise TypeError('link должен быть строкой') - + raise TypeError("link должен быть строкой") + parsed = urlparse(link) - + # Валидация схемы и домена - if parsed.scheme != 'https': - raise ValueError('Ссылка должна использовать https схему') - if parsed.netloc != 'max.ru': - raise ValueError('Ссылка должна указывать на домен max.ru') - + if parsed.scheme != "https": + raise ValueError("Ссылка должна использовать https схему") + if parsed.netloc != "max.ru": + raise ValueError("Ссылка должна указывать на домен max.ru") + # Валидация пути: /c/{chat_id}/{seq_b64} - path_parts = parsed.path.strip('/').split('/') - if len(path_parts) != 3 or path_parts[0] != 'c': - raise ValueError('Неверный формат пути в ссылке. Ожидается: /c/{chat_id}/{seq_b64}') - + path_parts = parsed.path.strip("/").split("/") + if len(path_parts) != 3 or path_parts[0] != "c": + raise ValueError( + "Неверный формат пути в ссылке. Ожидается: /c/{chat_id}/{seq_b64}") + # Извлечение и валидация chat_id try: chat_id = int(path_parts[1]) except ValueError as e: - raise ValueError('chat_id в ссылке должен быть целым числом') from e - + raise ValueError("chat_id в ссылке должен быть целым числом") from e + _validate_chat_id(chat_id) - + # Извлечение seq_b64 seq_b64 = path_parts[2] - - if not seq_b64 or not re.fullmatch(r'[A-Za-z0-9_-]+', seq_b64): - raise ValueError('seq в ссылке должен быть в url-safe base64 формате') - + + if not seq_b64 or not re.fullmatch(r"[A-Za-z0-9_-]+", seq_b64): + raise ValueError("seq в ссылке должен быть в url-safe base64 формате") + # Добавляем паддинг для корректного декодирования base64 # Длина base64 должна быть кратна 4 padding_needed = (4 - len(seq_b64) % 4) % 4 - seq_b64_padded = seq_b64 + '=' * padding_needed - + seq_b64_padded = seq_b64 + "=" * padding_needed + try: # Декодируем из url-safe base64 seq_bytes = base64.urlsafe_b64decode(seq_b64_padded) except (binascii.Error, ValueError) as e: - raise ValueError(f'Ошибка декодирования base64: {e}') from e - + raise ValueError(f"Ошибка декодирования base64: {e}") from e + # Валидация длины: seq должен быть 8 байт (64 бита) if len(seq_bytes) != 8: - raise ValueError('seq должен быть представлен 8 байтами (64 бита)') - + raise ValueError("seq должен быть представлен 8 байтами (64 бита)") + # Конвертируем байты в int (big-endian, unsigned) - seq = int.from_bytes(seq_bytes, byteorder='big') - + seq = int.from_bytes(seq_bytes, byteorder="big") + return chat_id, seq diff --git a/tests/test_message_url.py b/tests/test_message_url.py new file mode 100644 index 0000000..7859e56 --- /dev/null +++ b/tests/test_message_url.py @@ -0,0 +1,196 @@ +"""Тесты для свойства Message.url с объединённой логикой. + +Того что приходит присылает API: +Только для постов в канале вида https://max.ru/{channel_name}/{seq_b64} + +И генерированной ссылки для диалогов и групповых чатов: +https://max.ru/c/{chat_id}/{seq_b64} + +""" + +from maxapi.types.message import Message + + +class TestMessageUrlProperty: + """Тесты для свойства url в модели Message.""" + + def test_url_from_api_channel_post(self): + """URL из API для поста в канале — возвращается как есть.""" + api_url = "https://max.ru/news_channel/AZ2H-TzaAOc" + data = { + "url": api_url, + "recipient": { + "chat_id": None, + "user_id": None, + "chat_type": "channel", + }, + "timestamp": 1234567890, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + "text": "Channel post", + }, + } + msg = Message.model_validate(data) + + assert msg.url == api_url + assert msg.url_api == api_url + dumped = msg.model_dump() + assert dumped["url"] == api_url + assert "url_api" not in dumped + + + def test_url_generated_for_dialog(self): + """Для диалога без url из API — ссылка генерируется из body.mid.""" + data = { + "recipient": { + "chat_id": -73455901853123, + "user_id": None, + "chat_type": "dialog", + }, + "timestamp": 1234567890, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + "text": "Hello", + }, + } + msg = Message.model_validate(data) + + assert msg.url == "https://max.ru/c/-73455901853123/AZ2H-TzaAOc" + assert msg.url_api is None + assert msg.model_dump()["url"] is None + + + def test_url_generated_for_group_chat(self): + """Для группового чата без url из API — ссылка генерируется.""" + data = { + "recipient": { + "chat_id": -71955698945289, + "user_id": None, + "chat_type": "chat", + }, + "timestamp": 1234567890, + "body": { + "mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554, + "text": "Chat group message", + }, + } + msg = Message.model_validate(data) + + assert msg.url == "https://max.ru/c/-71955698945289/AZ113QDTS3o" + assert msg.url_api is None + assert msg.model_dump()["url"] is None + + def test_url_none_when_no_body(self): + """Если нет body — url возвращает None.""" + data = { + "recipient": { + "chat_id": 241387420, + "user_id": None, + "chat_type": "dialog", + }, + "timestamp": 1234567890, + } + msg = Message.model_validate(data) + + assert msg.url is None + assert msg.url_api is None + assert msg.model_dump()["url"] is None + + + def test_url_none_when_no_body_but_url_from_api(self): + """Если API прислал url, но нет body — возвращается url из API.""" + api_url = "https://max.ru/special_channel/AZ113QDTS3o" + data = { + "url": api_url, + "recipient": { + "chat_id": None, + "user_id": None, + "chat_type": "channel", + }, + "timestamp": 1234567890, + } + msg = Message.model_validate(data) + + assert msg.url == api_url + assert msg.url_api == api_url + assert msg.model_dump()["url"] == api_url + + def test_serialization_preserves_original_url(self): + """Сериализация сохраняет оригинальный url, а не сгенерированный.""" + api_url = "https://max.ru/channel/AZ2H-TzaAOc" + + # Случай 1: с url из API + msg_with_url = Message.model_validate({ + "url": api_url, + "recipient": {"chat_id": None, + "user_id": None, + "chat_type": "channel"}, + "timestamp": 123, + "body": {"mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431}, + }) + dumped = msg_with_url.model_dump() + assert dumped["url"] == api_url + + # Случай 2: без url из API + msg_no_url = Message.model_validate({ + "recipient": {"chat_id": 241387420, + "user_id": None, + "chat_type": "dialog"}, + "timestamp": 123, + "body": {"mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431}, + }) + dumped = msg_no_url.model_dump() + assert dumped["url"] is None + + def test_url_property_priority(self): + """url_api имеет приоритет над генерацией из body.""" + api_url = "https://max.ru/priority_test/AZ2H-TzaAOc" + data = { + "url": api_url, + "recipient": {"chat_id": 241387420, + "user_id": None, + "chat_type": "dialog"}, + "timestamp": 123, + "body": {"mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431}, + } + msg = Message.model_validate(data) + + assert msg.url == api_url + assert msg.url_api == api_url + + def test_url_with_negative_chat_id(self): + """Генерация ссылки работает с отрицательными chat_id (каналы).""" + expected_url = "https://max.ru/c/-71955698945289/AZ113QDTS3o" + + data = { + "recipient": {"chat_id": -71955698945289, + "user_id": None, + "chat_type": "channel"}, + "timestamp": 123, + "body": {"mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554}, + } + msg = Message.model_validate(data) + + assert msg.url == expected_url + + def test_model_dump_json_includes_url(self): + """model_dump(mode='json') включает url с правильным значением.""" + msg = Message.model_validate({ + "url": "https://max.ru/test/AZ113QDTS3o", + "recipient": {"chat_id": None, + "user_id": None, + "chat_type": "channel"}, + "timestamp": 123, + "body": {"mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554}, + }) + + dumped_json = msg.model_dump(mode="json") + assert dumped_json["url"] == "https://max.ru/test/AZ113QDTS3o" diff --git a/tests/test_utils/test_message_link.py b/tests/test_utils/test_message_link.py index 8846016..a397ac8 100644 --- a/tests/test_utils/test_message_link.py +++ b/tests/test_utils/test_message_link.py @@ -1,12 +1,12 @@ +import base64 import re import pytest -import base64 from maxapi.utils.message_link import ( - mid_to_chatid_seq, - chatid_seq_to_mid, build_message_link, + chatid_seq_to_mid, link_to_chatid_seq, + mid_to_chatid_seq, ) # ============================================================================= @@ -103,10 +103,10 @@ def test_full_pipeline(self, case): mid = chatid_seq_to_mid(case["chat_id"], case["seq"]) link = build_message_link(mid) chat_id_parsed, seq_parsed = link_to_chatid_seq(link) - + assert chat_id_parsed == case["chat_id"] assert seq_parsed == case["seq"] - + # Дополнительно: если была исходная ссылка, проверяем совпадение if case["link"]: assert link == case["link"] @@ -152,7 +152,7 @@ def test_seq_max_unsigned(self): seq = (1 << 64) - 1 mid = chatid_seq_to_mid(chat_id, seq) assert mid == "mid.0000000000000000ffffffffffffffff" - chat_id_restored, seq_restored = mid_to_chatid_seq(mid) + _, seq_restored = mid_to_chatid_seq(mid) assert seq_restored == seq def test_negative_chat_id_hex_representation(self): @@ -175,32 +175,38 @@ class TestValidationErrors: # ------------------ mid_to_chatid_seq ------------------ def test_mid_wrong_type(self): with pytest.raises(TypeError, match="mid должен быть строкой"): - mid_to_chatid_seq(12345) + mid_to_chatid_seq(12345) # type: ignore + + def test_mid_wrong_values(self): + match = re.escape('mid должен быть в формате "mid." + 32 hex-символа') - def test_mid_missing_prefix(self): - with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + # missing prefix + with pytest.raises(ValueError, match=match): mid_to_chatid_seq("000000000b68571c019d5eac630d58ce") - def test_mid_wrong_length_short(self): - with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + # wrong length short + with pytest.raises(ValueError, match=match): mid_to_chatid_seq("mid.000000000b68571c") - def test_mid_wrong_length_long(self): - with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + # wrong length long + with pytest.raises(ValueError, match=match): mid_to_chatid_seq("mid.000000000b68571c019d5eac630d58ce00") - def test_mid_invalid_hex_chars(self): - with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): - mid_to_chatid_seq("mid.000000000b68571g019d5eac630d58ce") # 'g' не hex + # invalid_hex_chars + with pytest.raises(ValueError, match=match): + # "g" не hex + mid_to_chatid_seq("mid.000000000b68571g019d5eac630d58ce") + # ------------------ chatid_seq_to_mid ------------------ def test_chatid_wrong_type(self): - with pytest.raises(TypeError, match="chat_id должен быть целым числом"): - chatid_seq_to_mid("123", 100) + with pytest.raises(TypeError, + match="chat_id должен быть целым числом"): + chatid_seq_to_mid("123", 100) # type: ignore def test_seq_wrong_type(self): with pytest.raises(TypeError, match="seq должен быть целым числом"): - chatid_seq_to_mid(123, "100") + chatid_seq_to_mid(123, "100") # type: ignore def test_chat_id_out_of_range_low(self): with pytest.raises(ValueError, match="chat_id выходит за пределы"): @@ -219,29 +225,35 @@ def test_seq_out_of_range(self): chatid_seq_to_mid(0, 1 << 64) # ------------------ build_message_link ------------------ - def test_link_wrong_mid_prefix(self): - with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + def test_link_wrong_mid(self): + match = re.escape('mid должен быть в формате "mid." + 32 hex-символа') + + # wrong mid prefix + with pytest.raises(ValueError, match=match): build_message_link("invalid.000000000b68571c019d5eac630d58ce") - def test_link_wrong_mid_format(self): - with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + # wrong_mid_format + with pytest.raises(ValueError, match=match): build_message_link("mid.000000000b68571g019d5eac630d58ce") - def test_link_wrong_mid_length(self): - with pytest.raises(ValueError, match=re.escape('mid должен быть в формате "mid." + 32 hex-символа')): + # wrong_mid_length + with pytest.raises(ValueError, match=match): build_message_link("mid.0000") + # ------------------ link_to_chatid_seq ------------------ def test_link_wrong_type(self): with pytest.raises(TypeError, match="link должен быть строкой"): - link_to_chatid_seq(12345) + link_to_chatid_seq(12345) # type: ignore def test_link_wrong_scheme(self): - with pytest.raises(ValueError, match="Ссылка должна использовать https схему"): + with pytest.raises(ValueError, + match="Ссылка должна использовать https схему"): link_to_chatid_seq("ftp://max.ru/c/123/ABC") def test_link_wrong_domain(self): - with pytest.raises(ValueError, match="Ссылка должна указывать на домен max.ru"): + with pytest.raises(ValueError, + match=r"Ссылка должна указывать на домен max.ru"): link_to_chatid_seq("https://example.com/c/123/ABC") def test_link_wrong_path_format(self): @@ -253,22 +265,25 @@ def test_link_wrong_path_parts_count(self): link_to_chatid_seq("https://max.ru/c/123") # нет seq def test_link_chat_id_not_int(self): - with pytest.raises(ValueError, match="chat_id в ссылке должен быть целым числом"): + with pytest.raises(ValueError, + match="chat_id в ссылке должен быть целым числом"): link_to_chatid_seq("https://max.ru/c/abc/ABC") def test_link_invalid_base64_chars(self): - with pytest.raises(ValueError, match="должен быть в url-safe base64 формате"): + with pytest.raises(ValueError, + match="должен быть в url-safe base64 формате"): link_to_chatid_seq("https://max.ru/c/123/ABC@#$") def test_link_base64_decode_error(self): # Некорректный base64 (невозможно декодировать) - with pytest.raises(ValueError, match="seq в ссылке должен быть в url-safe base64 формате"): - link_to_chatid_seq("https://max.ru/c/123/!!!") + with pytest.raises(ValueError, match="Ошибка декодирования base64"): + link_to_chatid_seq("https://max.ru/c/123/A") def test_link_seq_wrong_byte_length(self): # Base64, который декодируется не в 8 байт # "AQ" -> 1 байт после декодирования - with pytest.raises(ValueError, match="seq должен быть представлен 8 байтами"): + with pytest.raises(ValueError, + match="seq должен быть представлен 8 байтами"): link_to_chatid_seq("https://max.ru/c/123/AQ") @@ -298,16 +313,22 @@ def test_link_seq_base64_urlsafe_no_padding(self): link = build_message_link(mid) seq_part = link.split("/")[-1] assert "=" not in seq_part # нет паддинга - assert all(c in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" for c in seq_part) + assert all( + c in ("ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789-_") + for c in seq_part + ) def test_link_seq_decoding_consistency(self): """Декодирование seq из ссылки должно давать исходное значение""" - + def _seq_to_b64(seq: int) -> str: """Вспомогательная функция: seq → URL-safe base64 без паддинга.""" - return base64.urlsafe_b64encode(seq.to_bytes(8, "big")).decode().rstrip("=") - + seq_bytes = seq.to_bytes(8, "big") + return base64.urlsafe_b64encode(seq_bytes).decode().rstrip("=") + test_cases = [ 0, # Минимум (все биты 0) 1, # Единица (проверка младших битов) @@ -330,7 +351,7 @@ def _seq_to_b64(seq: int) -> str: # ============================================================================= # Параметризованные тесты для покрытия различных сценариев # ============================================================================= -@pytest.mark.parametrize("chat_id,seq", [ +@pytest.mark.parametrize(("chat_id", "seq"), [ (0, 0), (1, 1), (-1, 1), @@ -345,7 +366,7 @@ def test_parametrized_roundtrip(chat_id: int, seq: int): chat_id_restored, seq_restored = mid_to_chatid_seq(mid) assert chat_id_restored == chat_id assert seq_restored == seq - + link = build_message_link(mid) chat_id_from_link, seq_from_link = link_to_chatid_seq(link) assert chat_id_from_link == chat_id From e00d1190cd1f6a5af4cea1b0a4da1cb1c984c91f Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:10:08 +0300 Subject: [PATCH 4/6] ruff reformatted --- maxapi/types/message.py | 1 + maxapi/utils/message_link.py | 12 ++- tests/test_message_url.py | 101 ++++++++++++++++---------- tests/test_utils/test_message_link.py | 81 ++++++++++++--------- 4 files changed, 116 insertions(+), 79 deletions(-) diff --git a/maxapi/types/message.py b/maxapi/types/message.py index dd10810..a97d72c 100644 --- a/maxapi/types/message.py +++ b/maxapi/types/message.py @@ -329,6 +329,7 @@ class Message( Может быть None в случае отсутвия body. bot (Optional[Bot]): Объект бота, исключается из сериализации. """ + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) sender: User | None = None recipient: Recipient diff --git a/maxapi/utils/message_link.py b/maxapi/utils/message_link.py index 7961682..7c9f386 100644 --- a/maxapi/utils/message_link.py +++ b/maxapi/utils/message_link.py @@ -8,7 +8,9 @@ def _validate_chat_id(chat_id: int) -> None: """Проверяет, что chat_id в диапазоне signed int64.""" if chat_id < -(1 << 63) or chat_id >= (1 << 63): raise ValueError( - "chat_id выходит за пределы знакового 64-битного диапазона") + "chat_id выходит за пределы знакового 64-битного диапазона" + ) + def mid_to_chatid_seq(mid: str) -> tuple[int, int]: """ @@ -77,7 +79,8 @@ def chatid_seq_to_mid(chat_id: int, seq: int) -> str: _validate_chat_id(chat_id) if seq < 0 or seq >= (1 << 64): raise ValueError( - "seq выходит за пределы беззнакового 64-битного диапазона") + "seq выходит за пределы беззнакового 64-битного диапазона" + ) # Битовая маска гарантирует корректное hex-представление для signed int # (отрицательные числа автоматически преобразуются в two"s complement) @@ -103,7 +106,7 @@ def build_message_link(mid: str) -> str: ValueError: Если mid не соответствует формату "mid." + 32 hex-символа """ - chat_id, seq = mid_to_chatid_seq(mid) # Валидация происходит здесь + chat_id, seq = mid_to_chatid_seq(mid) # Валидация происходит здесь # 1. Преобразуем seq в 8 байт (big-endian) seq_bytes = seq.to_bytes(8, byteorder="big") @@ -146,7 +149,8 @@ def link_to_chatid_seq(link: str) -> tuple[int, int]: path_parts = parsed.path.strip("/").split("/") if len(path_parts) != 3 or path_parts[0] != "c": raise ValueError( - "Неверный формат пути в ссылке. Ожидается: /c/{chat_id}/{seq_b64}") + "Неверный формат пути в ссылке. Ожидается: /c/{chat_id}/{seq_b64}" + ) # Извлечение и валидация chat_id try: diff --git a/tests/test_message_url.py b/tests/test_message_url.py index 7859e56..2f037f6 100644 --- a/tests/test_message_url.py +++ b/tests/test_message_url.py @@ -39,7 +39,6 @@ def test_url_from_api_channel_post(self): assert dumped["url"] == api_url assert "url_api" not in dumped - def test_url_generated_for_dialog(self): """Для диалога без url из API — ссылка генерируется из body.mid.""" data = { @@ -61,7 +60,6 @@ def test_url_generated_for_dialog(self): assert msg.url_api is None assert msg.model_dump()["url"] is None - def test_url_generated_for_group_chat(self): """Для группового чата без url из API — ссылка генерируется.""" data = { @@ -99,7 +97,6 @@ def test_url_none_when_no_body(self): assert msg.url_api is None assert msg.model_dump()["url"] is None - def test_url_none_when_no_body_but_url_from_api(self): """Если API прислал url, но нет body — возвращается url из API.""" api_url = "https://max.ru/special_channel/AZ113QDTS3o" @@ -123,27 +120,39 @@ def test_serialization_preserves_original_url(self): api_url = "https://max.ru/channel/AZ2H-TzaAOc" # Случай 1: с url из API - msg_with_url = Message.model_validate({ - "url": api_url, - "recipient": {"chat_id": None, - "user_id": None, - "chat_type": "channel"}, - "timestamp": 123, - "body": {"mid": "mid.ffffbd3137103a3d019d87f93cda00e7", - "seq": 116398669919027431}, - }) + msg_with_url = Message.model_validate( + { + "url": api_url, + "recipient": { + "chat_id": None, + "user_id": None, + "chat_type": "channel", + }, + "timestamp": 123, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + }, + } + ) dumped = msg_with_url.model_dump() assert dumped["url"] == api_url # Случай 2: без url из API - msg_no_url = Message.model_validate({ - "recipient": {"chat_id": 241387420, - "user_id": None, - "chat_type": "dialog"}, - "timestamp": 123, - "body": {"mid": "mid.ffffbd3137103a3d019d87f93cda00e7", - "seq": 116398669919027431}, - }) + msg_no_url = Message.model_validate( + { + "recipient": { + "chat_id": 241387420, + "user_id": None, + "chat_type": "dialog", + }, + "timestamp": 123, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + }, + } + ) dumped = msg_no_url.model_dump() assert dumped["url"] is None @@ -152,12 +161,16 @@ def test_url_property_priority(self): api_url = "https://max.ru/priority_test/AZ2H-TzaAOc" data = { "url": api_url, - "recipient": {"chat_id": 241387420, - "user_id": None, - "chat_type": "dialog"}, + "recipient": { + "chat_id": 241387420, + "user_id": None, + "chat_type": "dialog", + }, "timestamp": 123, - "body": {"mid": "mid.ffffbd3137103a3d019d87f93cda00e7", - "seq": 116398669919027431}, + "body": { + "mid": "mid.ffffbd3137103a3d019d87f93cda00e7", + "seq": 116398669919027431, + }, } msg = Message.model_validate(data) @@ -169,12 +182,16 @@ def test_url_with_negative_chat_id(self): expected_url = "https://max.ru/c/-71955698945289/AZ113QDTS3o" data = { - "recipient": {"chat_id": -71955698945289, - "user_id": None, - "chat_type": "channel"}, + "recipient": { + "chat_id": -71955698945289, + "user_id": None, + "chat_type": "channel", + }, "timestamp": 123, - "body": {"mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", - "seq": 116378757443570554}, + "body": { + "mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554, + }, } msg = Message.model_validate(data) @@ -182,15 +199,21 @@ def test_url_with_negative_chat_id(self): def test_model_dump_json_includes_url(self): """model_dump(mode='json') включает url с правильным значением.""" - msg = Message.model_validate({ - "url": "https://max.ru/test/AZ113QDTS3o", - "recipient": {"chat_id": None, - "user_id": None, - "chat_type": "channel"}, - "timestamp": 123, - "body": {"mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", - "seq": 116378757443570554}, - }) + msg = Message.model_validate( + { + "url": "https://max.ru/test/AZ113QDTS3o", + "recipient": { + "chat_id": None, + "user_id": None, + "chat_type": "channel", + }, + "timestamp": 123, + "body": { + "mid": "mid.ffffbe8e821ff2f7019d75dd00d34b7a", + "seq": 116378757443570554, + }, + } + ) dumped_json = msg.model_dump(mode="json") assert dumped_json["url"] == "https://max.ru/test/AZ113QDTS3o" diff --git a/tests/test_utils/test_message_link.py b/tests/test_utils/test_message_link.py index a397ac8..3a0ba34 100644 --- a/tests/test_utils/test_message_link.py +++ b/tests/test_utils/test_message_link.py @@ -175,7 +175,7 @@ class TestValidationErrors: # ------------------ mid_to_chatid_seq ------------------ def test_mid_wrong_type(self): with pytest.raises(TypeError, match="mid должен быть строкой"): - mid_to_chatid_seq(12345) # type: ignore + mid_to_chatid_seq(12345) # type: ignore def test_mid_wrong_values(self): match = re.escape('mid должен быть в формате "mid." + 32 hex-символа') @@ -197,16 +197,16 @@ def test_mid_wrong_values(self): # "g" не hex mid_to_chatid_seq("mid.000000000b68571g019d5eac630d58ce") - # ------------------ chatid_seq_to_mid ------------------ def test_chatid_wrong_type(self): - with pytest.raises(TypeError, - match="chat_id должен быть целым числом"): - chatid_seq_to_mid("123", 100) # type: ignore + with pytest.raises( + TypeError, match="chat_id должен быть целым числом" + ): + chatid_seq_to_mid("123", 100) # type: ignore def test_seq_wrong_type(self): with pytest.raises(TypeError, match="seq должен быть целым числом"): - chatid_seq_to_mid(123, "100") # type: ignore + chatid_seq_to_mid(123, "100") # type: ignore def test_chat_id_out_of_range_low(self): with pytest.raises(ValueError, match="chat_id выходит за пределы"): @@ -240,20 +240,21 @@ def test_link_wrong_mid(self): with pytest.raises(ValueError, match=match): build_message_link("mid.0000") - # ------------------ link_to_chatid_seq ------------------ def test_link_wrong_type(self): with pytest.raises(TypeError, match="link должен быть строкой"): - link_to_chatid_seq(12345) # type: ignore + link_to_chatid_seq(12345) # type: ignore def test_link_wrong_scheme(self): - with pytest.raises(ValueError, - match="Ссылка должна использовать https схему"): + with pytest.raises( + ValueError, match="Ссылка должна использовать https схему" + ): link_to_chatid_seq("ftp://max.ru/c/123/ABC") def test_link_wrong_domain(self): - with pytest.raises(ValueError, - match=r"Ссылка должна указывать на домен max.ru"): + with pytest.raises( + ValueError, match=r"Ссылка должна указывать на домен max.ru" + ): link_to_chatid_seq("https://example.com/c/123/ABC") def test_link_wrong_path_format(self): @@ -265,13 +266,15 @@ def test_link_wrong_path_parts_count(self): link_to_chatid_seq("https://max.ru/c/123") # нет seq def test_link_chat_id_not_int(self): - with pytest.raises(ValueError, - match="chat_id в ссылке должен быть целым числом"): + with pytest.raises( + ValueError, match="chat_id в ссылке должен быть целым числом" + ): link_to_chatid_seq("https://max.ru/c/abc/ABC") def test_link_invalid_base64_chars(self): - with pytest.raises(ValueError, - match="должен быть в url-safe base64 формате"): + with pytest.raises( + ValueError, match="должен быть в url-safe base64 формате" + ): link_to_chatid_seq("https://max.ru/c/123/ABC@#$") def test_link_base64_decode_error(self): @@ -282,8 +285,9 @@ def test_link_base64_decode_error(self): def test_link_seq_wrong_byte_length(self): # Base64, который декодируется не в 8 байт # "AQ" -> 1 байт после декодирования - with pytest.raises(ValueError, - match="seq должен быть представлен 8 байтами"): + with pytest.raises( + ValueError, match="seq должен быть представлен 8 байтами" + ): link_to_chatid_seq("https://max.ru/c/123/AQ") @@ -314,13 +318,15 @@ def test_link_seq_base64_urlsafe_no_padding(self): seq_part = link.split("/")[-1] assert "=" not in seq_part # нет паддинга assert all( - c in ("ABCDEFGHIJKLMNOPQRSTUVWXYZ" - "abcdefghijklmnopqrstuvwxyz" - "0123456789-_") + c + in ( + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "0123456789-_" + ) for c in seq_part ) - def test_link_seq_decoding_consistency(self): """Декодирование seq из ссылки должно давать исходное значение""" @@ -330,11 +336,11 @@ def _seq_to_b64(seq: int) -> str: return base64.urlsafe_b64encode(seq_bytes).decode().rstrip("=") test_cases = [ - 0, # Минимум (все биты 0) - 1, # Единица (проверка младших битов) - 255, # Граница 1 байта (0xFF) - 2**64 - 1, # Максимум unsigned int64 (все биты 1) - 116353259870705870, # Реальное значение из продакшена + 0, # Минимум (все биты 0) + 1, # Единица (проверка младших битов) + 255, # Граница 1 байта (0xFF) + 2**64 - 1, # Максимум unsigned int64 (все биты 1) + 116353259870705870, # Реальное значение из продакшена ] for seq in test_cases: expected_b64 = _seq_to_b64(seq) @@ -351,15 +357,18 @@ def _seq_to_b64(seq: int) -> str: # ============================================================================= # Параметризованные тесты для покрытия различных сценариев # ============================================================================= -@pytest.mark.parametrize(("chat_id", "seq"), [ - (0, 0), - (1, 1), - (-1, 1), - (999999999, 999999999), - (-999999999, 999999999), - (2**62, 2**63), # большие значения в пределах диапазона - (-(2**62), 2**63 - 1), -]) +@pytest.mark.parametrize( + ("chat_id", "seq"), + [ + (0, 0), + (1, 1), + (-1, 1), + (999999999, 999999999), + (-999999999, 999999999), + (2**62, 2**63), # большие значения в пределах диапазона + (-(2**62), 2**63 - 1), + ], +) def test_parametrized_roundtrip(chat_id: int, seq: int): """Параметризованный тест кругового преобразования""" mid = chatid_seq_to_mid(chat_id, seq) From 5862e2016091c7910e10423b4a69d20c192ba67c Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Thu, 30 Apr 2026 13:30:40 +0300 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20doc=20strings=20=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=80=D1=84=D0=BE=D0=B3=D1=80=D0=B0=D1=84=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- maxapi/types/message.py | 4 ++-- maxapi/utils/message_link.py | 2 +- tests/test_message_url.py | 9 +++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/maxapi/types/message.py b/maxapi/types/message.py index a97d72c..c95b411 100644 --- a/maxapi/types/message.py +++ b/maxapi/types/message.py @@ -324,9 +324,9 @@ class Message( url_api (Optional[str]): URL сообщения из ответа API. Публичная ссылка на пост в канале. Отсутствует для диалогов и групповых чатов - url (Optional[str]): Генерируемое свойсто URL сообщения. + url (Optional[str]): Генерируемое свойство URL сообщения. Дополняет ответ API для приватных чатов и групп - Может быть None в случае отсутвия body. + Может быть None в случае отсутствия body. bot (Optional[Bot]): Объект бота, исключается из сериализации. """ diff --git a/maxapi/utils/message_link.py b/maxapi/utils/message_link.py index 7c9f386..1670b1d 100644 --- a/maxapi/utils/message_link.py +++ b/maxapi/utils/message_link.py @@ -83,7 +83,7 @@ def chatid_seq_to_mid(chat_id: int, seq: int) -> str: ) # Битовая маска гарантирует корректное hex-представление для signed int - # (отрицательные числа автоматически преобразуются в two"s complement) + # (отрицательные числа автоматически преобразуются в two's complement) chat_id_hex = f"{chat_id & 0xFFFFFFFFFFFFFFFF:016x}" seq_hex = f"{seq:016x}" diff --git a/tests/test_message_url.py b/tests/test_message_url.py index 2f037f6..76ee2f9 100644 --- a/tests/test_message_url.py +++ b/tests/test_message_url.py @@ -1,10 +1,11 @@ """Тесты для свойства Message.url с объединённой логикой. -Того что приходит присылает API: -Только для постов в канале вида https://max.ru/{channel_name}/{seq_b64} +Проверяются два формата URL: -И генерированной ссылки для диалогов и групповых чатов: -https://max.ru/c/{chat_id}/{seq_b64} +1. URL, полученный от API для постов в канале: + https://max.ru/{channel_name}/{seq_b64} +2. URL, сгенерированный для диалогов и групповых чатов: + https://max.ru/c/{chat_id}/{seq_b64} """ From 8780d7b40d39bb73a52c066af8fb4a1e4e74babf Mon Sep 17 00:00:00 2001 From: Pankovea <114323250+Pankovea@users.noreply.github.com> Date: Fri, 1 May 2026 00:19:55 +0300 Subject: [PATCH 6/6] ruff --- maxapi/types/message.py | 6 ++++-- maxapi/utils/message_link.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/maxapi/types/message.py b/maxapi/types/message.py index c95b411..62b8a79 100644 --- a/maxapi/types/message.py +++ b/maxapi/types/message.py @@ -649,8 +649,10 @@ def url(self) -> str | None: Returns: str: Ссылка на сообщение в формате - - Для диалогов и групповых чатов: https://max.ru/c/{chat_id}/{seq_b64} - - Постов в канале: https://max.ru/{channel_name}/{seq_b64} + - Для диалогов и групповых чатов: + https://max.ru/c/{chat_id}/{seq_b64} + - Постов в канале: + https://max.ru/{channel_name}/{seq_b64} None: Если объект Message не содержит в себе body """ if self.url_api: diff --git a/maxapi/utils/message_link.py b/maxapi/utils/message_link.py index 1670b1d..214dfeb 100644 --- a/maxapi/utils/message_link.py +++ b/maxapi/utils/message_link.py @@ -120,7 +120,8 @@ def link_to_chatid_seq(link: str) -> tuple[int, int]: """ Парсит ссылку на сообщение в интерфейсе MAX и извлекает chat_id и seq. - Не обрабатываются ссылки на публичные каналы вида https://max.ru/{channel_name}/{urlsafe_base64} + Не обрабатываются ссылки на публичные каналы вида + https://max.ru/{channel_name}/{urlsafe_base64} Только приватные чаты и группы. Args: