Skip to content

feat: утилита генерации ссылок на сообщения#113

Open
Pankovea wants to merge 6 commits intolove-apples:mainfrom
Pankovea:feat_build_message_link
Open

feat: утилита генерации ссылок на сообщения#113
Pankovea wants to merge 6 commits intolove-apples:mainfrom
Pankovea:feat_build_message_link

Conversation

@Pankovea
Copy link
Copy Markdown

@Pankovea Pankovea commented Apr 14, 2026

Реализация инструментов для работы с идентификаторами сообщений (mid)

Описание:

Добавлены вспомогательные методы для корректной обработки внутренних идентификаторов сообщений MAX (mid) и генерации публичных ссылок на сообщения.

Для чего это нужно:

  • Хранение в БД. Теперь не нужно хранить три значения. Достаточно хранить только mid или только пару (chat_id, sec). И при необходимости можно получить одно из другого.

Изменения:

  • build_message_link:
    Добавлена генерация прямой ссылки на сообщение в формате https://max.ru/c/{chat_id}/{seq_b64}.
    Реализовано кодирование seq в URL-safe Base64 без padding-символов (=).
    Добавлена валидация входного параметра mid (проверка префикса, длины и hex-формата).

  • mid_to_chatid_seq:
    Реализован декодер строки mid в пару (chat_id, seq).
    Учтена специфика хранения chat_id как signed 64-bit integer при hex-кодировании (корректная обработка отрицательных значений через two's complement).
    seq интерпретируется как unsigned 64-bit integer.

  • chatid_seq_to_mid:
    Реализован обратный конвертер из (chat_id, seq) в строковый формат mid.
    Гарантируется фиксированная длина hex-строк (по 16 символов на компонент) и корректное битовое маскирование для отрицательных chat_id.

Пример использования:

Для тестовых данных:
Конвертация работает следующим образом:

>>> chatid_seq_to_mid(100000001, 123456789012345678)
'mid.0000000005f5e10101b69b4ba630f34e'

>>> mid_to_chatid_seq('mid.0000000005f5e10101b69b4ba630f34e')
(100000001, 123456789012345678)

>>> build_message_link('mid.0000000005f5e10101b69b4ba630f34e')
'https://max.ru/c/100000001/AbabS6Yw804'

# Для отрицательных chat_id
>>> chatid_seq_to_mid(-700000001, 123456789012345678)
'mid.ffffffffd646d8ff01b69b4ba630f34e'

>>> mid_to_chatid_seq('mid.ffffffffd646d8ff01b69b4ba630f34e')
(-700000001, 123456789012345678)

>>> build_message_link('mid.ffffffffd646d8ff01b69b4ba630f34e')
'https://max.ru/c/-700000001/AbabS6Yw804'

Тестирование:

Работа ссылок проверена на реальном проекте - ссылки работают.

@Olegt0rr Olegt0rr requested a review from Copilot April 14, 2026 19:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

PR добавляет утилиты для работы с внутренними идентификаторами сообщений MAX (mid) и генерации публичных ссылок на сообщения (для использования в веб-интерфейсе).

Changes:

  • Добавлены конвертеры mid ↔ (chat_id, seq) с учётом signed int64 для chat_id (two’s complement) и uint64 для seq.
  • Добавлена генерация ссылки на сообщение https://max.ru/c/{chat_id}/{seq_b64} с URL-safe Base64 без padding.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread maxapi/utils/message.py Outdated
Comment thread maxapi/utils/message.py Outdated
Comment thread maxapi/utils/message.py Outdated
Comment thread maxapi/utils/message.py Outdated
Comment thread maxapi/utils/message.py Outdated
Comment thread maxapi/utils/message.py Outdated
Comment thread maxapi/utils/message.py Outdated
@bish-x
Copy link
Copy Markdown
Contributor

bish-x commented Apr 15, 2026

Привет, @Pankovea! Посмотрел PR подробнее — накидываю пачкой всё, что нашёл сверх ревью Copilot. Список выглядит длинным, но половина — это мелочь «на минуту», и если тебе удобнее — я могу сам запушить фиксы в твою ветку (скажи — сделаю), либо готов скинуть готовые сниппеты/тесты, выбирай как удобнее. Идея утилиты классная, хочется чтобы она доехала до merge без регрессий.

Блокеры (функция в текущем виде не запускается)

  1. base64 не импортированutils/message.py:196 использует base64.urlsafe_b64encode, но import base64 нет. При вызове — NameError: name 'base64' is not defined. Проверил вживую на ветке: python -c \"from maxapi.utils.message import build_message_link; build_message_link(...)\" падает.
  2. Сигнатура build_message_link(self, mid) и self.mid_to_chatid_seq(mid)utils/message.py:175,191. Это модульная функция, а не метод. build_message_link(\"mid.0000000005f5e10101b69b4ba630a60e\") падает с TypeError: build_message_link() missing 1 required positional argument: 'mid'. Надо убрать self и вызывать mid_to_chatid_seq(mid) напрямую.

Несогласованность примера в описании PR

  1. В описании заявлено, что chatid_seq_to_mid(100000001, 123456789012345678) даёт \"mid.0000000005f5e10101b69b4ba630a60e\". Но hex от 123456789012345678 — это 0x01b69b4ba630f34e, а не 0x01b69b4ba630a60e. Реальное decoding строки из примера: mid_to_chatid_seq(\"mid.0000000005f5e10101b69b4ba630a60e\") = (100000001, 123456789012325902). То есть в примере либо не тот seq, либо не та mid-строка — стоит поправить, чтобы не путать пользователей (и самого себя при дебаге).

Семантика и устойчивость

  1. chatid_seq_to_mid не валидирует диапазон seq. seq = -1 даёт mid.0000000000000000-000000000000001 (литеральный минус внутри mid!), seq = 2**64 даёт 37-символьную строку. Copilot тут предложил маскировать через & 0xFFFFFFFFFFFFFFFF — я бы наоборот, явно проверил 0 <= seq < 2**64 и -2**63 <= chat_id < 2**63 с ValueError, чтобы мусор не превращался тихо в валидно выглядящий mid.
  2. mid_to_chatid_seq без валидации'mid.' + 'a'*100 проходит без исключения и возвращает гигантский seq (проверил). Копировать ту же валидацию префикса/длины/hex из build_message_link сюда тоже надо.
  3. Дырка в hex-валидации. int('+aaaa...', 16) Python принимает (знак разрешён), поэтому mid.+aaaa... длиной 36 проходит всю валидацию в build_message_link. Лучше одним re.fullmatch(r'mid\.[0-9a-fA-F]{32}', mid) закрыть и длину, и префикс, и множество символов.
  4. seq < 0 или seq >= 2**64 в build_message_link — на to_bytes(8, 'big') вылетает OverflowError, а не человеческий ValueError. После исправления (4) этот кейс закроется сам по сути.
  5. Порядок проверок в build_message_link. Сейчас 'mid.' (пустое тело) проходит prefix-check, падает на hex-check и пользователь видит «должно быть в hex-формате», хотя настоящая проблема — пустая строка. Если переставить length-check перед hex-check, сообщения будут точнее.

Тесты

  1. Тестов на новые функции нет — Codecov у репы де-факто требует 100 % patch coverage. Минимум, который ожидаю от Olegt0rr:

    • round-trip chatid_seq_to_mid ↔ mid_to_chatid_seq на граничных chat_id ∈ {0, 1, -1, INT64_MIN, INT64_MAX} и seq ∈ {0, 1, 2**64 - 1} (проверил — логика round-trip’а корректна на всём диапазоне);
    • негативные тесты валидации: нет префикса, короткая/длинная строка, non-hex, '+'-трюк;
    • фикс-вектор: build_message_link(chatid_seq_to_mid(100000001, 123456789012345678)) → ожидаемый URL (выбирать вектор уже с исправленными числами из п.3);
    • формат base64: без =, символы только из [A-Za-z0-9_-], длина 11 для 8 байт.

    Если хочешь — скину готовый tests/test_utils/test_message_link.py как патч, просто скажи.

Мелочь

  1. maxapi/utils/__init__.py пустой — новые публичные функции нигде не ре-экспортированы. Нужно ли их тянуть в from maxapi.utils import ... — вопрос к @love-apples (см. ниже).
  2. Нет \n в конце файла.
  3. В сообщении ValueError('длина hex значениея mid должена быть 32 символа') — опечатки значениея → значения, должена → должна.

Архитектурный вопрос к @love-apples

  1. У Message уже есть поле url: Optional[str] (его сервер заполняет готовой ссылкой на сообщение — maxapi/types/message.py). А MessageBody уже хранит и mid, и seq. Утилита из PR полезна именно для ситуации, когда у пользователя есть сырой mid без Message-объекта (логи, внешний источник) — это валидный кейс, но хочется услышать мнение: оставляем как модульную утилиту, или переносим в метод MessageBody.get_link() + оставляем голые конвертеры как low-level util? И главное:

  2. Формат URL https://max.ru/c/{chat_id}/{seq_b64} где-то задокументирован MAX'ом? В tests/test_serialization_models.py:103 фигурирует живой URL из ShareAttachmentPayload вида https://max.ru/c/x/AZ2I.bkQ — с точкой в последнем сегменте, которой нет в URL-safe Base64 алфавите [A-Za-z0-9_-]. Возможно это просто мок, но хочется удостовериться, что сгенерированная ссылка реально открывается в MAX-клиенте до того, как шипанём это в релиз. Если есть официальная ссылка на документацию формата — киньте, если нет — стоит руками потыкать на живом сообщении.


@Pankovea, ещё раз: пунктов много, но почти все тривиальные — если хочешь, могу пушнуть фикс-коммит прямо в твою ветку (тогда с тебя остаётся только review + ответ на п.13–14). Или скину diff / тесты в комментарий — как удобнее.

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 15, 2026

Спасибо за ревью.

Назначение.

  1. В Макс для некоторых методов нужен chat_id, a для некоторых, mid.
    Если хранить в БД. То в итоге достаточно хранить mid и из него получит всё что нужно.
    Или же хранить chat_is и seq, но где нужно получить mid.

  2. Не видел в реальных ответах Макс, чтобы приходили ссылки на сообщения.

  3. В документации нету. Это по форумам. Но работоспособность проверена. Этот код был в рабочем проекте. (Да импорты забыл. Среда не настроена толком. Тесты не запускаются локально.)

  4. Как пример. Ссылку на сообщение можно получить прямо из интерфейса MAX приложения. Сообщение -
    Я бы оставил в утилитах для использования отдельно от сообщений (только в групповых чатах). Например

link_to_chatid_seq('https://max.ru/c/-73455000000003/AZ2KuJN1Z8A')
(-73455000000003, 116401690734061504)

Правки внесу.

@Pankovea
Copy link
Copy Markdown
Author

Почему-то не получается запустить тесты локально

ImportError while loading conftest 'D:\TMP\maxapi\tests\conftest.py'.
tests\conftest.py:35: in <module>
    from maxapi import Bot, Dispatcher
maxapi\__init__.py:1: in <module>
    from .bot import Bot
maxapi\bot.py:11: in <module>
    from .connection.base import DOWNLOAD_CHUNK_SIZE, BaseConnection
maxapi\connection\base.py:19: in <module>
    from ..types.bot_mixin import BotMixin
maxapi\types\__init__.py:1: in <module>
    from ..filters.command import Command, CommandStart
maxapi\filters\__init__.py:3: in <module>
    from .channel_post import ChannelPostFilter
maxapi\filters\channel_post.py:5: in <module>
    from ..types.updates.message_created import MessageCreated
maxapi\types\updates\__init__.py:5: in <module>
    from ...types.updates.bot_added import BotAdded
maxapi\types\updates\bot_added.py:4: in <module>
    from ...types.users import User
maxapi\types\users.py:6: in <module>
    from ..utils.formatting import UserMention
maxapi\utils\__init__.py:1: in <module>
    from .message import (
maxapi\utils\message.py:12: in <module>
    from ..types.attachments.upload import AttachmentPayload, AttachmentUpload
maxapi\types\attachments\__init__.py:5: in <module>
    from ..attachments.audio import Audio
maxapi\types\attachments\audio.py:4: in <module>
    from .attachment import Attachment
maxapi\types\attachments\attachment.py:8: in <module>
    from ...types.users import User
E   ImportError: cannot import name 'User' from partially initialized module 'maxapi.types.users' (most likely due to a circular import) (D:\TMP\maxapi\maxapi\types\users.py)

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 15, 2026

Попробуем тут прогнать тесты.
А я не могу запустить Copilot ?

@bish-x
Copy link
Copy Markdown
Contributor

bish-x commented Apr 15, 2026

Привет, @Pankovea! Спасибо, что быстро раскатал правки — почти всё закрыто, осталось немного, что мешает CI стать зелёным. Локально прогнал ещё раз, группирую по приоритетам с точными местами и почему так.

1. Блокер: circular import — ломается весь пакет, не только новый код

Именно по этой причине у тебя локально не стартует conftest с cannot import name 'User'. Это не твоя среда, это сам PR. Логика:

  • На main maxapi/utils/__init__.py пустой, поэтому maxapi/types/users.py:6from ..utils.formatting import UserMention грузит только utils/formatting.py, и цикла нет.
  • В PR maxapi/utils/__init__.py теперь реэкспортирует из utils/message.py. А utils/message.py:12 импортирует from ..types.attachments.upload import AttachmentPayload, AttachmentUpload (нужно только для process_input_media, не для четырёх новых функций). Этот импорт через types/attachments/attachment.py:8 возвращается в types/users.py — и Python падает с partial init.

Воспроизводимо на твоей ветке:

python -c "from maxapi.utils.formatting import UserMention"
ImportError: cannot import name 'User' from partially initialized module 'maxapi.types.users'

Т.е. сейчас from maxapi import Bot в conftest.py падает ещё до сбора тестов, CI красный на import-stage.

Самый чистый вариант фикса — вынести четыре новые функции в отдельный файл maxapi/utils/message_link.py (он использует только stdlib: base64, re, urllib.parse), а utils/__init__.py реэкспортирует уже оттуда:

  • создать maxapi/utils/message_link.py с mid_to_chatid_seq, chatid_seq_to_mid, build_message_link, link_to_chatid_seq;
  • удалить эти же функции из maxapi/utils/message.py (плюс убрать добавленные там import base64, import re, from urllib.parse import urlparse);
  • в maxapi/utils/__init__.py заменить from .message import (...) на from .message_link import (...);
  • в тестах tests/test_utils/test_message_build_link.py:2-7from maxapi.utils.message_link import (...) (или from maxapi.utils import (...), оба варианта сработают).

Так utils/__init__.py больше не тянет тяжёлые импорты из types/attachments/* при старте, и цикл разрывается.

Альтернатива попроще — вернуть utils/__init__.py в пустое состояние и оставить пользователям from maxapi.utils.message import build_message_link. Снимает цикл без рефакторинга, но теряется красивый импорт from maxapi.utils import ....

2. Блокер: тесты валидации ждут сообщения, которых в коде уже нет

Во 2-м коммите ты схлопнул три проверки mid в один regex (maxapi/utils/message.py:156-157):

if not re.fullmatch(r'mid\.[0-9a-fA-F]{32}', mid):
    raise ValueError('mid должен быть в формате "mid." + 32 hex-символа')

Решение правильное — это закрывает и длину, и префикс, и hex-дырку с +-знаком. Но тесты в tests/test_utils/test_message_build_link.py остались от раннего варианта с тремя отдельными сообщениями. Запустил standalone-прогоном — DID NOT RAISE <regex> на:

Тест (строка) match=... ждёт Фактический raise
test_mid_missing_prefix:179 'mid должен начинаться с "mid."' 'mid должен быть в формате "mid." + 32 hex-символа'
test_mid_wrong_length_short:183 "длина hex значения mid должна быть 32 символа" то же объединённое
test_mid_wrong_length_long:187 "длина hex значения mid должна быть 32 символа" то же
test_mid_invalid_hex_chars:191 "должно быть в hex-формате" то же
test_link_wrong_mid_prefix:221 'mid должен начинаться с "mid."' то же (через build_message_linkmid_to_chatid_seq)
test_link_wrong_mid_format:225 "должно быть в hex-формате" то же
test_link_wrong_mid_length:229 "длина hex значения mid должна быть 32 символа" то же
test_link_wrong_scheme:238 "Ссылка должна использовать http или https схему" "Ссылка должна использовать https схему" (message.py:229)

Проще всего поправить тесты под текущий код: в первых 7 строчках — match='mid должен быть в формате', в test_link_wrong_schemematch='Ссылка должна использовать https'. Если нравится подробная разбивка ошибок — альтернативно в mid_to_chatid_seq вернуть три проверки подряд (startswith('mid.') → длина 36 → re.fullmatch('[0-9a-fA-F]{32}', mid[4:])) с тремя сообщениями, как раньше. По стилю репы — ок и то и то, но единый regex проще.

Ещё один смежный кейс — test_link_base64_decode_error:261-263:

with pytest.raises(ValueError, match="Ошибка декодирования base64"):
    link_to_chatid_seq("https://max.ru/c/123/!!!")

До base64.urlsafe_b64decode дело не дойдёт: regex на message.py:251 ([A-Za-z0-9_-]+) отсеет ! раньше и кинет "seq в ссылке должен быть в url-safe base64 формате". Т.е. сейчас except на 261-262 недостижим ни в одном кейсе. Два варианта:

  • либо убрать этот тест (ветка действительно мёртвая при текущем regex) и блок try/except можно сносить вместе с ним;
  • либо подставить reproducer, который проходит regex, но реально ломает декодер — например, один валидный символ "A" (regex пропустит, urlsafe_b64decode("A===") падает binascii.Error: Invalid base64-encoded string → попадает в наш handler). Тогда тест действительно начинает проверять декодер, а не regex-гейт.

3. Мелочи (не блокеры, но по практике Olegt0rr обычно просит)

  • maxapi/utils/message.py:260-262except Exception as e: raise ValueError(...) каталожно «слишком широкий», плюс нет from e (теряется traceback). Рекомендую except (binascii.Error, ValueError) as e: raise ValueError(f'Ошибка декодирования base64: {e}') from e + import binascii в шапку файла. Та же история на message.py:241-242 для int(path_parts[1]) — добавить as err и from err.
  • DRY: проверка chat_id < -(1 << 63) or chat_id >= (1 << 63) дублируется — chatid_seq_to_mid:181-182 и link_to_chatid_seq:244-245. В репе от второго вхождения ждут выноса в helper, например _validate_chat_id(chat_id: int) -> None.
  • maxapi/utils/message.py — опечатка в docstring link_to_chatid_seq: {channe_name}{channel_name}.
  • Docstring-консистентность: у link_to_chatid_seq есть Returns:, у остальных трёх — нет. Дотянуть до Args/Returns/Raises у всех четырёх — ближе к стилю maxapi/utils/formatting.py.
  • tests/test_utils/test_message_build_link.py:302-316 (test_link_seq_decoding_consistency): в табличке test_cases стоит (255, "AAAAAAAAAAP"), но реальное base64 от 255 = "AAAAAAAAAP8" (последний байт \xffP8). Ассерт эту константу не сверяет (проверяется round-trip через seq_from_link), так что тест не падает — но читатель в таблицу смотрит и верит. Либо заменить значение на правильное и добавить assert seq_from_link == expected_b64, либо убрать колонку.

4. Открытый архитектурный вопрос — к @love-apples

Пока повисло с первого прохода: у Message уже есть поле url: Optional[str] (сервер заполняет готовой ссылкой), а MessageBody хранит и mid, и seq. Твои утилиты ценны именно для ситуаций, когда Message-объекта нет (сырой mid из логов/БД/внешнего источника) — это валидный сценарий. Вопрос к @love-apples: оставляем как модульные утилиты в maxapi.utils, или добавляем поверх метод MessageBody.get_link() и оставляем голые конвертеры как low-level util? И отдельно: в tests/test_serialization_models.py:103 в ShareAttachmentPayload присутствует URL вида https://max.ru/c/x/AZ2I.bkQс точкой в последнем сегменте, которой нет в url-safe base64 алфавите. Это артефакт теста/мока или другой формат ссылки, который тоже стоит понимать link_to_chatid_seq?


Как и раньше — если хочешь, соберу патч-коммитом всё по пунктам 1–3 и кину тебе PR в ветку feat_build_message_link (или diff в комментарий, на git apply). Скажи как удобнее. Идея реально полезная, осталось совсем чуть-чуть до мержа.

@love-apples
Copy link
Copy Markdown
Owner

@Pankovea проперти функцию сделай пж у Message для получения ссылки

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 15, 2026

@Pankovea проперти функцию сделай пж у Message для получения ссылки

Отличное предложение. Но в утилитах на мой взгляд надо всё равно оставить.

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 15, 2026

Настроил всё же запуск тестов. Отрабатывают все кроме интеграционных.

Я сделал message.url_link потому что message.link уже используется в edit_message и тесты падали.

Спасибо за терпение. Это очень продуктивный опыт для меня.

@bish-x
Copy link
Copy Markdown
Contributor

bish-x commented Apr 16, 2026

@Pankovea огромный прогресс за эти сутки — circular import закрыт через вынос в maxapi/utils/message_link.py, 65 тестов зелёные, сообщения в pytest.raises(match=...) выровнены под текущий regex, _validate_chat_id helper на месте, binascii.Error + from e на обоих try/except, опечатка channe_name поправлена, docstrings у всех четырёх функций с Args/Returns/Raises — это ровно тот «чистый» вариант, который обсуждали. И @love-apples-предложение про property тоже закрыл — Message.url_link появилось. Прогнал свежий fd2348b локально — осталось два момента, оба вокруг property, и ещё одна мелочь с покрытием. Подробности ниже, чтобы не пришлось гадать где и почему.

1. Блокер: Message.url_link падает с AttributeError в runtime

maxapi/types/message.py:604-609:

@property
def url_link(self):
    """
    Прямая ссылка на сообщение в интерфейсе MAX
    """
    return build_message_link(self.mid)

Проблема в том, что у класса Message нет поля mid — оно есть только у MessageBody. Проверил через pydantic:

>>> from maxapi.types.message import Message
>>> list(Message.model_fields)
['bot', 'sender', 'recipient', 'timestamp', 'link', 'body', 'stat', 'url']

mid: str объявлен в MessageBody (message.py:114), а все соседние методы Message, которым нужен mid, обращаются к нему через self.body.mid — см. строки 435 (reply), 492 (forward), 554 (delete), 577 (edit_text), 600 (pin). Т.е. это консистентный паттерн по всему классу, и self.mid в property просто откалывается от остальных.

Минимальный repro — одна строка в python -c:

from maxapi.types.message import Message, MessageBody, Recipient
from maxapi.enums.chat_type import ChatType
msg = Message(
    sender=None,
    recipient=Recipient(chat_type=ChatType.DIALOG),
    timestamp=0,
    body=MessageBody(mid='mid.000000000b68571c019d5eac630d58ce', seq=123),
)
msg.url_link
# AttributeError: 'Message' object has no attribute 'mid'

Дополнительный нюанс — body бывает None. Это не гипотеза, а реальный инвариант самой модели: все методы, работающие с body.mid, имеют раннюю проверку вида if self.body is None: raise ValueError(...):

  • message.py:452-454 в forward
  • message.py:549-551 в delete
  • message.py:572-574 в edit_text
  • message.py:590-592 в pin
  • message.py:421-423 в reply

Значит у тебя два решения (оба валидны, зависит от вкуса мейнтейнера):

Вариант A — property возвращает str | None (мягко, не кидает):

@property
def url_link(self) -> str | None:
    """
    Прямая ссылка на сообщение в интерфейсе MAX.
    Возвращает ``None``, если у сообщения отсутствует body.
    """
    if self.body is None:
        return None
    return build_message_link(self.body.mid)

Плюс: property безопасно использовать в шаблонах / f-strings без try/except. Минус: None надо обрабатывать у пользователя.

Вариант B — property кидает ValueError, как соседи (строго, симметрично остальным методам класса):

@property
def url_link(self) -> str:
    """Прямая ссылка на сообщение в интерфейсе MAX."""
    if self.body is None:
        raise ValueError('Невозможно построить ссылку: поле body отсутствует у сообщения')
    return build_message_link(self.body.mid)

Плюс: стиль ровно такой же, как у pin, delete, edit_text. Минус: message.url_link в шаблоне требует проверку.

Я бы предложил вариант A — property обычно используют для логирования/отображения, и мягкий None там удобнее, плюс конвенция Python (property кидает редко, если атрибут есть). Но если @love-apples скажет «пусть кидает как соседи» — B не хуже, главное, чтобы тип возврата в аннотации и документация точно отражали поведение.

2. Блок except (binascii.Error, ValueError) по-прежнему мёртвый + дубликат теста

maxapi/utils/message_link.py:155:

if not seq_b64 or not re.fullmatch(r'[A-Za-z0-9_-]+', seq_b64):
    raise ValueError('seq в ссылке должен быть в url-safe base64 формате')

Этот regex отсекает любой символ, не входящий в url-safe base64 алфавит, ещё до base64.urlsafe_b64decode. В итоге до блока на message_link.py:170-172:

try:
    seq_bytes = base64.urlsafe_b64decode(seq_b64_padded)
except (binascii.Error, ValueError) as e:
    raise ValueError(f'Ошибка декодирования base64: {e}') from e

исполнение не доходит ни в одном кейсе, который я смог придумать. Тест tests/test_utils/test_message_link.py:261-264:

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/!!!')

после того как ты поменял expected-сообщение под текущий код, стал дубликатом test_link_invalid_base64_chars:257-259:

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@#$')

Оба теста проверяют одну и ту же ветку — regex на 155. Т.е. покрытие не выросло, а handler на 170-172 остался «на всякий случай», но никогда не срабатывает.

Два варианта:

Вариант 1 — снести мёртвый код и тест:

  • убрать try/except binascii.Error на 170-172, оставить голый seq_bytes = base64.urlsafe_b64decode(seq_b64_padded);
  • убрать импорт binascii (он больше не нужен);
  • удалить test_link_base64_decode_error целиком.

Честнее и короче. Минус — теряется defense-in-depth на случай, если кто-то в будущем ослабит regex.

Вариант 2 — найти валидный reproducer, который проходит regex, но ломает декодер. Такой кейс есть: одиночный "A". Regex [A-Za-z0-9_-]+ пропускает, длина 1 → padding 3 → получится "A===", и base64.urlsafe_b64decode("A===") падает binascii.Error: Invalid base64-encoded string: number of data characters 1 cannot be 1 more than a multiple of 4. Проверено:

>>> import base64; base64.urlsafe_b64decode("A===")
binascii.Error: Invalid base64-encoded string: ...

Тогда тест выглядит так:

def test_link_base64_decode_error(self):
    """Валидные символы, но base64-декодирование падает (одиночный символ)."""
    with pytest.raises(ValueError, match='Ошибка декодирования base64'):
        link_to_chatid_seq('https://max.ru/c/123/A')

И он реально проверяет handler на 170-172.

Выбор между (1) и (2) — дело вкуса, я бы сделал (2): handler дешёвый, а binascii.Error у stdlib в поведении менялся между версиями Python, так что defensive-ветка оправдана.

3. Нет теста на Message.url_link

Как property добавишь (после фикса из §1), нужен хотя бы один-два тест-кейса — по практике репо на codecov держат ~100% patch coverage, и каждая property с поведением обычно покрывается. Можно в том же tests/test_utils/test_message_link.py или в отдельном файле типа tests/test_message_url_link.py:

from maxapi.types.message import Message, MessageBody, Recipient
from maxapi.enums.chat_type import ChatType

def _make_message(mid: str) -> Message:
    return Message(
        sender=None,
        recipient=Recipient(chat_type=ChatType.DIALOG),
        timestamp=0,
        body=MessageBody(mid=mid, seq=0),
    )

def test_url_link_happy_path():
    msg = _make_message('mid.000000000b68571c019d5eac630d58ce')
    assert msg.url_link == 'https://max.ru/c/191387420/AZ1erGMNWM4'

def test_url_link_without_body():
    msg = Message(
        sender=None,
        recipient=Recipient(chat_type=ChatType.DIALOG),
        timestamp=0,
        body=None,
    )
    # Вариант A:
    assert msg.url_link is None
    # Вариант B (если выбрал ValueError):
    # import pytest
    # with pytest.raises(ValueError, match='body отсутствует'):
    #     msg.url_link

Плюс хорошо бы негативный кейс — mid с невалидным форматом пролезет через MessageBody.mid: str (pydantic не проверяет формат, только тип), и property внутри упадёт ValueError из mid_to_chatid_seq. Это уже поведение самой утилиты, но стоит хотя бы проверить, что ошибка пробрасывается как есть, без лишних обёрток.

Мелочь, не блокер

Коммит-сообщения последних трёх коммитов (bugfixes. Тесты проходят успешно, add property message.url_link, docstrings, fixes from code review) — без Conventional-префиксов. В репе стиль feat:/fix:/refactor: обязателен в заголовке итогового коммита, но @love-apples почти всегда мёржит PR как squash и сам переписывает заголовок, так что это не блокер мержа. Можешь оставить как есть.


Итого чек-лист на следующий коммит:

  • Поправить Message.url_link (self.body.mid + проверка body is None, один из двух вариантов).
  • Решить судьбу except (binascii.Error, ValueError) и test_link_base64_decode_error — или снести оба, или поменять reproducer на "A".
  • Добавить два-три теста на url_link (happy path + body=None).

После этого PR реально готов к мержу. По-прежнему готов собрать патч и кинуть тебе diff или PR-в-PR, если так быстрее.

@Pankovea
Copy link
Copy Markdown
Author

Pankovea commented Apr 16, 2026

ОК. Спасибо.
Сначала засунул проперти в body, а потом перенёс и забыл исправить. Тестов, нет поэтому не выявили.

Нужен совет.
Из документации Max API:
Message.url - string Nullable optional
Публичная ссылка на пост в канале. Отсутствует для диалогов и групповых чатов

Теперь мы расширяем это свойство.

Я бы не добавлял отдельный url_link - что может запутать пользователя
нужно приходящие данные сохранять в _url (но как. я не знаю как быть с pydantic)
А в нашем проперти объединять данные

    @property
    def url(self) -> str | None:
        """
        Прямая ссылка на сообщение в интерфейсе MAX

        Returns:
            str: Ссылка на сообщение в формате
                - Для диалогов и групповых чатов: https://max.ru/c/{chat_id}/{seq_b64}
                - Постов в канале: https://max.ru/c/{channel_name}/{seq_b64}
            None: Если объект Message не содержит в себе body
        """
        if self._url:
            return self._url
        elif self.body:
            return build_message_link(self.body.mid)
        else:
            return None

@love-apples
Copy link
Copy Markdown
Owner

Ну да, хороший вариант

@Pankovea
Copy link
Copy Markdown
Author

С pydantic помогла разобраться нейронка.
с подчркивания pydantic не позволяет делать свойства
поэтому я назвал url_api

        url_api (Optional[str]): URL сообщения из ответа API.
                                Публичная ссылка на пост в канале.
                                Отсутствует для диалогов и групповых чатов
        url (Optional[str]): Генерируемое свойсто URL сообщения.
                            Дополняет ответ API для приватных чатов и групп
                            Может быть None в случае отсутвия body.

После model_dump возвращает только то что было получено от сервера.

        assert dumped["url"] == api_url
        assert "url_api" not in dumped

@Pankovea
Copy link
Copy Markdown
Author

Видимо надо опять rebase сделать... Или не нужно? Файлы вроде разные затронуты.

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 19, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a dedicated utility for working with MAX message identifiers (mid) and generating/parsing public message links, and integrates this into the Message model via a computed url property while preserving the raw API-provided URL.

Changes:

  • Added maxapi.utils.message_link with converters (mid(chat_id, seq)) and link helpers (mid → URL, URL → (chat_id, seq)).
  • Updated Message to store the raw API URL as url_api (alias "url") and expose a computed url property that generates /c/{chat_id}/{seq_b64} when needed.
  • Added comprehensive tests for the new utility and for the Message.url behavior.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
maxapi/utils/message_link.py New utility module implementing mid parsing/encoding and link building/parsing.
maxapi/utils/__init__.py Exposes the new utility functions via maxapi.utils.
maxapi/types/message.py Adds url_api (alias url) + computed url property that falls back to generated links.
tests/test_utils/test_message_link.py New test suite for mid/link conversions, edge cases, and validation errors.
tests/test_message_url.py New tests ensuring Message.url prioritizes API URL and generates fallback links.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_utils/test_message_link.py Outdated
Comment thread tests/test_message_url.py Outdated
Comment thread maxapi/types/message.py Outdated
Comment thread maxapi/types/message.py Outdated
Comment thread maxapi/utils/message_link.py Outdated
Comment thread maxapi/utils/message_link.py Outdated
Comment thread maxapi/utils/message_link.py Outdated
Comment thread tests/test_utils/test_message_link.py
Comment thread tests/test_utils/test_message_link.py
Comment thread tests/test_message_url.py
Copy link
Copy Markdown
Collaborator

@Olegt0rr Olegt0rr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Инструмент удобный, но MR требует более тщательной подготовки: исправления опечаток и качественного форматирования (явно не был использован pre-commit). Большинство автоматизированных замечаний по-делу.

* build_message_link
* Конвертаци:
    mid_to_chatid_seq
    chatid_seq_to_mid

add: Валидация функций
add: link_to_chatid_seq - обратное преобразование
* Валидация mid перенесена в функцию mid_to_chatid_seq
add: Тесты на реальных данных проходят успешно.
* API для каналов
* Вычисленное для групповых и приватных чатов
* Более реальные примеры в тестах
add: tests message.url
refactor: ruff
@Pankovea Pankovea force-pushed the feat_build_message_link branch from e99eb84 to 52d39b3 Compare April 23, 2026 12:13
@Pankovea
Copy link
Copy Markdown
Author

Внёс ruff правки
Произвёл rebase
Схлопнул некоторые коммиты squash (bugfixes)

@Pankovea
Copy link
Copy Markdown
Author

Olegt0rr, мне не очень понятно что ещё нужно изменить.
love-apples approved these changes

@Pankovea Pankovea requested a review from Olegt0rr April 29, 2026 08:32
@Olegt0rr
Copy link
Copy Markdown
Collaborator

Olegt0rr commented Apr 30, 2026

@Pankovea во вкладке с кодом есть тонны комментариев от copilot - нужно изучить все. Там, где исправлено - написать, что оно исправлено. Там, где ИИ галлюционирует - написать что так делать не надо и почему. После этого тегнуть меня - я пробегу оперативно и позакрываю.

Например вижу, что замечания по форматированию были поправлены - их можно позакрывать самостоятельно.

Мы же хотим принести функциональность ничего не сломав и ничего не ухудшив? )

@Pankovea
Copy link
Copy Markdown
Author

Olegt0rr, всё пробежался. Там пару мест с орфографическими ошибками. Всё подправил.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Добавляет утилиты для преобразования внутренних идентификаторов сообщений MAX (mid) ↔︎ (chat_id, seq) и генерации/парсинга публичных ссылок на сообщения, а также дополняет модель Message вычисляемым URL.

Changes:

  • Добавлен модуль maxapi.utils.message_link с конвертерами mid/(chat_id, seq) и функциями build_message_link + link_to_chatid_seq.
  • В Message добавлено поле url_api (alias "url") и свойство url, которое при отсутствии url_api генерирует ссылку из body.mid.
  • Добавлены тесты на конвертацию и URL-логику (tests/test_utils/test_message_link.py, tests/test_message_url.py).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
maxapi/utils/message_link.py Реализация конвертеров mid↔︎(chat_id, seq) и генерации/парсинга ссылок.
maxapi/utils/init.py Экспорт новых утилит через __all__.
maxapi/types/message.py Добавляет url_api (alias "url") и property url с генерацией ссылки из mid.
tests/test_utils/test_message_link.py Полное покрытие: roundtrip, edge cases, валидация ошибок, ссылки.
tests/test_message_url.py Тесты поведения Message.url/url_api и сериализации.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +229 to +230
match = re.escape('mid должен быть в формате "mid." + 32 hex-символа')

Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Повторяющаяся строка с re.escape(...) здесь тоже выглядит длиннее 79 символов и может упасть по Ruff E501. Если вынести это значение в одну константу/фикстуру (и при необходимости разбить на части), исчезнут и дублирование, и проблема с длиной строки.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Pankovea фиксани, плз, и общий ruff прогон

Copy link
Copy Markdown
Author

@Pankovea Pankovea Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Здесь 78 символов. Укладывается в ограничения. Ruff не ругается локально.

  2. Никак не могу заставить ruff локально найти исправления. Говорит что всё чётко.

$ uv run ruff format ./tests/test_utils/test_message_link.py 
1 file left unchanged
$ uv run ruff format ./maxapi/utils/message_link.py
1 file left unchanged 

Проверил версии. Всё сходится с CI

$ uv --version
uv 0.11.8 (0e961dd9a 2026-04-27 x86_64-pc-windows-msvc)
$ uv run python --version
Python 3.11.15

python 3.10 тоже не показывает исправлений.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

понял в чём дело. Ты сделал коммит по совету второго пилота, но эти правки как раз и приводят к тому что ruff не пропускает.
uv run ruff format ./tests/test_utils/test_message_link.py
1 file reformatted

uv run ruff format --diff ./tests/test_utils/test_message_link.py 
--- tests\test_utils\test_message_link.py
+++ tests\test_utils\test_message_link.py
@@ -178,10 +178,7 @@
             mid_to_chatid_seq(12345)  # type: ignore
 
     def test_mid_wrong_values(self):
-        match = re.escape(
-            'mid должен быть в формате "mid." + '
-            "32 hex-символа"
-        )
+        match = re.escape('mid должен быть в формате "mid." + 32 hex-символа')
 
         # missing prefix
         with pytest.raises(ValueError, match=match):

В общем сейчас сделаю force push с моими правками с учётом ruff

Comment thread maxapi/utils/message_link.py Outdated
Comment thread maxapi/utils/message_link.py
Comment thread maxapi/types/message.py Outdated
Comment thread tests/test_utils/test_message_link.py
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@Olegt0rr
Copy link
Copy Markdown
Collaborator

Olegt0rr, всё пробежался. Там пару мест с орфографическими ошибками. Всё подправил.

Отлично! Остался последний комментарий.
Новую волну мелких - я закрыл автоматом по предложениям копайлота.
После его правки - делай автофикс ruff и готово.
У копайлота больше претензий нет.
У меня - тоже, вроде всё ясно-понятно.

@Pankovea Pankovea force-pushed the feat_build_message_link branch from c27bda0 to 8780d7b Compare April 30, 2026 21:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants