aiogram-max 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ """aiogram-max: запускает aiogram-бота в мессенджере MAX.
2
+
3
+ Код бота не меняется — подменяется только транспорт::
4
+
5
+ from aiogram import Dispatcher
6
+ from aiogram_max import make_bot
7
+
8
+ bot = make_bot(max_token="...")
9
+ await Dispatcher().start_polling(bot)
10
+ """
11
+
12
+ from aiogram_max.bot import create_bot, make_bot
13
+ from aiogram_max.errors import (
14
+ AiogramMaxError,
15
+ MaxApiError,
16
+ NotImplementedYet,
17
+ UnsupportedByMax,
18
+ )
19
+ from aiogram_max.markup import MarkupPolicy
20
+ from aiogram_max.session import (
21
+ MAX_API_URL,
22
+ NOT_IMPLEMENTED_PR_WELCOME,
23
+ MaxSession,
24
+ UnsupportedPolicy,
25
+ )
26
+
27
+ __all__ = [
28
+ "MAX_API_URL",
29
+ "NOT_IMPLEMENTED_PR_WELCOME",
30
+ "AiogramMaxError",
31
+ "MarkupPolicy",
32
+ "MaxApiError",
33
+ "MaxSession",
34
+ "NotImplementedYet",
35
+ "UnsupportedByMax",
36
+ "UnsupportedPolicy",
37
+ "create_bot",
38
+ "make_bot",
39
+ ]
aiogram_max/bot.py ADDED
@@ -0,0 +1,75 @@
1
+ """Сборка aiogram-бота, говорящего в MAX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ from aiogram import Bot
7
+
8
+ from aiogram_max.markup import MarkupPolicy
9
+ from aiogram_max.session import MAX_API_URL, MaxSession, UnsupportedPolicy
10
+
11
+ # aiogram проверяет форму токена: "<цифры>:<непустая строка>", а Bot.id парсит
12
+ # из левой части. Токен MAX такой формы не имеет, поэтому левую часть
13
+ # подставляем сами — туда идёт настоящий id бота. Сам токен уходит в
14
+ # MaxSession и дальше в заголовок Authorization.
15
+ _UNKNOWN_BOT_ID = 0
16
+
17
+
18
+ def make_bot(
19
+ max_token: str,
20
+ *,
21
+ bot_id: int = _UNKNOWN_BOT_ID,
22
+ api_url: str = MAX_API_URL,
23
+ unsupported: UnsupportedPolicy = UnsupportedPolicy.WARN,
24
+ markup: MarkupPolicy = MarkupPolicy.CONVERT,
25
+ client: httpx.AsyncClient | None = None,
26
+ **bot_kwargs: object,
27
+ ) -> Bot:
28
+ """Вернуть обычный ``aiogram.Bot``, ходящий в MAX вместо Telegram.
29
+
30
+ Всё, что выше транспорта — Dispatcher, роутеры, фильтры, FSM, типы —
31
+ остаётся стандартным aiogram.
32
+
33
+ ``bot_id`` нужен, только если важен ``bot.id`` (мультиботовые сценарии,
34
+ ключи в логах). Не знаете id — берите :func:`create_bot`, он спросит
35
+ его у MAX сам.
36
+ """
37
+ session = MaxSession(
38
+ max_token,
39
+ api_url=api_url,
40
+ unsupported=unsupported,
41
+ markup=markup,
42
+ client=client,
43
+ )
44
+ return Bot(token=f"{bot_id}:max", session=session, **bot_kwargs) # type: ignore[arg-type]
45
+
46
+
47
+ async def create_bot(
48
+ max_token: str,
49
+ *,
50
+ api_url: str = MAX_API_URL,
51
+ unsupported: UnsupportedPolicy = UnsupportedPolicy.WARN,
52
+ markup: MarkupPolicy = MarkupPolicy.CONVERT,
53
+ client: httpx.AsyncClient | None = None,
54
+ **bot_kwargs: object,
55
+ ) -> Bot:
56
+ """То же, что :func:`make_bot`, но с настоящим ``bot.id`` из ``GET /me``.
57
+
58
+ Один лишний запрос на старте — зато ``bot.id`` не ноль, а username
59
+ доезжает до фильтра ``Command``, который разбирает ``/start@bot``.
60
+ """
61
+ probe = make_bot(max_token, api_url=api_url, unsupported=unsupported, client=client)
62
+ try:
63
+ me = await probe.get_me()
64
+ finally:
65
+ await probe.session.close()
66
+
67
+ return make_bot(
68
+ max_token,
69
+ bot_id=me.id,
70
+ api_url=api_url,
71
+ unsupported=unsupported,
72
+ markup=markup,
73
+ client=client,
74
+ **bot_kwargs,
75
+ )
@@ -0,0 +1,279 @@
1
+ """Перевод между моделями MAX и aiogram.
2
+
3
+ Это единственное место, где живут знания о форме payload'ов MAX. Всё
4
+ остальное работает в терминах aiogram-типов.
5
+
6
+ Схемы MAX сверены с моделями библиотеки maxapi 1.2.1:
7
+ * update: {update_type, timestamp, ...}
8
+ * message: {sender: User, recipient: {user_id, chat_id, chat_type},
9
+ timestamp, body: {mid, seq, text, attachments}}
10
+ * user: {user_id, first_name, last_name, username, is_bot}
11
+ * callback: {timestamp, callback_id, payload, user}
12
+ """
13
+
14
+ from collections.abc import Callable
15
+ from datetime import UTC, datetime
16
+ from typing import Any
17
+
18
+ from aiogram.types import (
19
+ AcceptedGiftTypes,
20
+ CallbackQuery,
21
+ Chat,
22
+ ChatFullInfo,
23
+ ChatMemberAdministrator,
24
+ ChatMemberMember,
25
+ ChatMemberOwner,
26
+ Document,
27
+ InlineKeyboardMarkup,
28
+ Message,
29
+ PhotoSize,
30
+ Update,
31
+ User,
32
+ )
33
+
34
+ # MAX различает диалог с ботом и групповой чат; Telegram — private/group/channel.
35
+ _CHAT_TYPE = {"dialog": "private", "chat": "group", "channel": "channel"}
36
+
37
+
38
+ def chat_type(max_type: str | None) -> str:
39
+ return _CHAT_TYPE.get(max_type or "dialog", "private")
40
+
41
+
42
+ def to_user(raw: dict[str, Any] | None) -> User | None:
43
+ """MAX user → aiogram User."""
44
+ if not raw:
45
+ return None
46
+ return User(
47
+ id=raw["user_id"],
48
+ is_bot=bool(raw.get("is_bot", False)),
49
+ # first_name в Telegram обязателен, в MAX может не прийти.
50
+ first_name=raw.get("first_name") or raw.get("name") or "MAX user",
51
+ last_name=raw.get("last_name"),
52
+ username=raw.get("username"),
53
+ )
54
+
55
+
56
+ def to_chat(recipient: dict[str, Any], sender: dict[str, Any] | None) -> Chat:
57
+ """MAX recipient → aiogram Chat.
58
+
59
+ ``recipient.user_id`` в запасной путь НЕ годится: на живых событиях видно,
60
+ что это получатель конкретного сообщения, а не собеседник. В сообщении от
61
+ юзера боту там лежит id бота, в сообщении бота юзеру — id юзера. Если
62
+ подставить его как chat_id, бот в какой-то момент начнёт отвечать сам
63
+ себе, причём молча. Поэтому запасной путь только через отправителя.
64
+ """
65
+ cid = recipient.get("chat_id")
66
+ if cid is None and sender and not sender.get("is_bot"):
67
+ cid = sender.get("user_id")
68
+ return Chat(id=int(cid or 0), type=chat_type(recipient.get("chat_type")))
69
+
70
+
71
+ def to_attachments(
72
+ raw_attachments: list[dict[str, Any]] | None,
73
+ ) -> dict[str, Any]:
74
+ """MAX attachments → поля aiogram Message (document / photo).
75
+
76
+ У MAX нет file_id и метода getFile: вложение приходит готовым URL внутри
77
+ payload. Кладём этот URL в ``file_id`` — сессия отдаёт его обратно как
78
+ ``file_path``, и ``bot.download`` скачивает по прямой ссылке.
79
+ """
80
+ fields: dict[str, Any] = {}
81
+ for att in raw_attachments or []:
82
+ payload = att.get("payload") or {}
83
+ url = payload.get("url")
84
+ if not url:
85
+ continue
86
+ kind = att.get("type")
87
+ if kind == "image" and "photo" not in fields:
88
+ fields["photo"] = [
89
+ PhotoSize(file_id=url, file_unique_id=url, width=0, height=0)
90
+ ]
91
+ elif kind in {"file", "audio", "video"} and "document" not in fields:
92
+ fields["document"] = Document(
93
+ file_id=url,
94
+ file_unique_id=url,
95
+ file_name=att.get("filename"),
96
+ file_size=att.get("size"),
97
+ )
98
+ return fields
99
+
100
+
101
+ def to_message(raw: dict[str, Any]) -> Message:
102
+ """MAX message → aiogram Message.
103
+
104
+ ``message_id`` берём из body.seq: он целочисленный и монотонный внутри
105
+ чата, тогда как MAX-идентификатор ``mid`` — строка. Соответствие
106
+ seq → mid держит сессия, оно нужно для правки и удаления.
107
+ """
108
+ body = raw.get("body") or {}
109
+ sender = raw.get("sender")
110
+ recipient = raw.get("recipient") or {}
111
+ return Message(
112
+ message_id=int(body.get("seq") or 0),
113
+ date=datetime.fromtimestamp(int(raw.get("timestamp", 0)) / 1000, tz=UTC),
114
+ chat=to_chat(recipient, sender),
115
+ from_user=to_user(sender),
116
+ text=body.get("text"),
117
+ **to_attachments(body.get("attachments")),
118
+ )
119
+
120
+
121
+ def to_update(raw: dict[str, Any], update_id: int) -> Update | None:
122
+ """MAX update → aiogram Update. None — если тип события нам не нужен."""
123
+ kind = raw.get("update_type")
124
+
125
+ if kind == "message_created":
126
+ return Update(update_id=update_id, message=to_message(raw["message"]))
127
+
128
+ if kind == "message_callback":
129
+ cb = raw["callback"]
130
+ message = raw.get("message")
131
+ clicker = to_user(cb.get("user"))
132
+ if clicker is None:
133
+ # Кто нажал — обязательное поле CallbackQuery в aiogram, и без него
134
+ # событие всё равно некуда роутить. Пропускаем как неизвестный тип,
135
+ # а не падаем: иначе один кривой апдейт застопорит весь polling.
136
+ return None
137
+ return Update(
138
+ update_id=update_id,
139
+ callback_query=CallbackQuery(
140
+ id=cb["callback_id"],
141
+ from_user=clicker,
142
+ # chat_instance в Telegram обязателен и используется только
143
+ # как ключ группировки; MAX аналога не имеет.
144
+ chat_instance=str(cb.get("callback_id")),
145
+ data=cb.get("payload"),
146
+ message=to_message(message) if message else None,
147
+ ),
148
+ )
149
+
150
+ if kind == "bot_started":
151
+ # Нажатие «Начать» в MAX — ближайший аналог /start в Telegram.
152
+ return Update(
153
+ update_id=update_id,
154
+ message=Message(
155
+ message_id=0,
156
+ date=datetime.fromtimestamp(int(raw.get("timestamp", 0)) / 1000, tz=UTC),
157
+ chat=Chat(id=int(raw.get("chat_id") or 0), type="private"),
158
+ from_user=to_user(raw.get("user")),
159
+ text="/start",
160
+ ),
161
+ )
162
+
163
+ return None
164
+
165
+
166
+ # Telegram HTML/Markdown → MAX format. MarkdownV2 у MAX аналога не имеет,
167
+ # ближайшее — markdown (CommonMark), о расхождении предупреждает вызывающий.
168
+ _PARSE_MODE = {"HTML": "html", "Markdown": "markdown", "MarkdownV2": "markdown"}
169
+
170
+
171
+ def parse_mode_to_format(parse_mode: str | None) -> str | None:
172
+ """aiogram parse_mode → MAX format."""
173
+ if parse_mode is None:
174
+ return None
175
+ return _PARSE_MODE.get(str(parse_mode))
176
+
177
+
178
+ def keyboard_to_attachment(
179
+ markup: InlineKeyboardMarkup | None,
180
+ degrade: Callable[[str, str], None] | None = None,
181
+ ) -> dict[str, Any] | None:
182
+ """aiogram InlineKeyboardMarkup → MAX attachment inline_keyboard.
183
+
184
+ MAX знает только callback-кнопки и ссылки. Кнопку, которой нет аналога,
185
+ отбрасываем — показать пользователю кнопку, которая ничего не делает,
186
+ хуже. Но не молча: сообщаем через ``degrade``.
187
+ """
188
+ if markup is None:
189
+ return None
190
+ rows: list[list[dict[str, Any]]] = []
191
+ for row in markup.inline_keyboard:
192
+ buttons: list[dict[str, Any]] = []
193
+ for btn in row:
194
+ if btn.callback_data is not None:
195
+ buttons.append(
196
+ {"type": "callback", "text": btn.text, "payload": btn.callback_data}
197
+ )
198
+ elif btn.url is not None:
199
+ buttons.append({"type": "link", "text": btn.text, "url": btn.url})
200
+ elif degrade is not None:
201
+ kind = next(
202
+ (
203
+ name
204
+ for name in (
205
+ "web_app",
206
+ "login_url",
207
+ "switch_inline_query",
208
+ "switch_inline_query_current_chat",
209
+ "callback_game",
210
+ "pay",
211
+ "copy_text",
212
+ )
213
+ if getattr(btn, name, None) is not None
214
+ ),
215
+ "кнопка неизвестного типа",
216
+ )
217
+ degrade(f"InlineKeyboardButton.{kind}", f"текст кнопки: {btn.text!r}")
218
+ if buttons:
219
+ rows.append(buttons)
220
+ if not rows:
221
+ return None
222
+ return {"type": "inline_keyboard", "payload": {"buttons": rows}}
223
+
224
+
225
+ # MAX-тип чата → телеграмный. «dialog» — личка, «chat» — группа.
226
+ _CHAT_TYPES = {"dialog": "private", "chat": "group", "channel": "channel"}
227
+
228
+
229
+ def to_chat_full_info(raw: dict[str, Any]) -> ChatFullInfo:
230
+ """MAX chat → aiogram ChatFullInfo (её возвращает getChat)."""
231
+ return ChatFullInfo(
232
+ id=raw["chat_id"],
233
+ type=_CHAT_TYPES.get(str(raw.get("type")), "group"),
234
+ title=raw.get("title"),
235
+ description=raw.get("description"),
236
+ invite_link=raw.get("link"),
237
+ accent_color_id=0,
238
+ max_reaction_count=0,
239
+ # Полей про подарки у MAX нет; aiogram требует объект — отдаём пустой.
240
+ accepted_gift_types=AcceptedGiftTypes(
241
+ unlimited_gifts=False,
242
+ limited_gifts=False,
243
+ unique_gifts=False,
244
+ premium_subscription=False,
245
+ gifts_from_channels=False,
246
+ ),
247
+ )
248
+
249
+
250
+ def to_chat_member(
251
+ raw: dict[str, Any],
252
+ ) -> ChatMemberOwner | ChatMemberAdministrator | ChatMemberMember:
253
+ """MAX participant → aiogram ChatMember.
254
+
255
+ Прав администратора MAX по отдельности не отдаёт — только флаг is_admin.
256
+ Поэтому телеграмные can_* проставляем в False: соврать «может всё»
257
+ опаснее, чем занизить, бот на это ориентируется в проверках доступа.
258
+ """
259
+ user = to_user(raw)
260
+ assert user is not None
261
+ if raw.get("is_owner"):
262
+ return ChatMemberOwner(user=user, is_anonymous=False)
263
+ if raw.get("is_admin"):
264
+ return ChatMemberAdministrator(
265
+ user=user,
266
+ can_be_edited=False,
267
+ is_anonymous=False,
268
+ can_manage_chat=True,
269
+ can_delete_messages=False,
270
+ can_manage_video_chats=False,
271
+ can_restrict_members=False,
272
+ can_promote_members=False,
273
+ can_change_info=False,
274
+ can_invite_users=False,
275
+ can_post_stories=False,
276
+ can_edit_stories=False,
277
+ can_delete_stories=False,
278
+ )
279
+ return ChatMemberMember(user=user)
aiogram_max/errors.py ADDED
@@ -0,0 +1,44 @@
1
+ """Ошибки слоя совместимости."""
2
+
3
+
4
+ class AiogramMaxError(Exception):
5
+ """Базовая ошибка библиотеки."""
6
+
7
+
8
+ class UnsupportedByMax(AiogramMaxError):
9
+ """У метода Telegram нет аналога в MAX — и не появится с нашей стороны.
10
+
11
+ Поднимается в строгом режиме. В режиме WARN метод логируется и
12
+ превращается в no-op: см. ``UnsupportedPolicy``.
13
+ """
14
+
15
+ def __init__(self, method_name: str) -> None:
16
+ super().__init__(
17
+ f"{method_name} не поддерживается MAX Bot API. "
18
+ "Уберите вызов или переключите MaxSession в режим WARN."
19
+ )
20
+ self.method_name = method_name
21
+
22
+
23
+ class NotImplementedYet(AiogramMaxError):
24
+ """Аналог в MAX есть, но мы его ещё не написали.
25
+
26
+ Отличать это от ``UnsupportedByMax`` важно: первое — предложение прислать
27
+ PR, второе — свойство самой платформы, и PR тут не поможет.
28
+ """
29
+
30
+ def __init__(self, method_name: str, hint: str = "") -> None:
31
+ super().__init__(
32
+ f"{method_name}: в MAX аналог есть, в aiogram-max ещё не написан. "
33
+ f"PR welcome! {hint}".strip()
34
+ )
35
+ self.method_name = method_name
36
+
37
+
38
+ class MaxApiError(AiogramMaxError):
39
+ """MAX вернул не-2xx ответ."""
40
+
41
+ def __init__(self, status: int, body: str) -> None:
42
+ super().__init__(f"MAX API вернул {status}: {body[:500]}")
43
+ self.status = status
44
+ self.body = body
aiogram_max/markup.py ADDED
@@ -0,0 +1,251 @@
1
+ """Перевод разметки Telegram в разметку MAX.
2
+
3
+ Цель — html, а не CommonMark, и это не вкусовщина. Проверка на живом API
4
+ (таблица в README) показала: тип ``underline`` MAX отдаёт только за ``<u>``.
5
+ Через markdown подчёркивание недостижимо в принципе — телеграмное
6
+ ``__текст__`` в CommonMark означает жирный, так что конверсия в markdown
7
+ молча превратила бы подчёркнутое в жирное. Плюс html не требует правил
8
+ экранирования, которых в MarkdownV2 полтора десятка, да ещё и разных внутри
9
+ кодовых спанов.
10
+
11
+ Входа два, и оба сходятся в html:
12
+
13
+ * ``entities`` — смещения, а не грамматика. Точный перевод, рендер берём
14
+ готовый из aiogram.
15
+ * ``parse_mode=MarkdownV2`` — нужен разбор текста, этим занят ``_Scanner``.
16
+
17
+ Чего у MAX нет вовсе — спойлер, цитата, кастомные эмодзи — уходит в
18
+ ``degrade``: текст сохраняется, оформление теряется, в лог пишется что
19
+ именно. Молча не теряем ничего.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from collections.abc import Callable, Iterable
25
+ from enum import StrEnum
26
+
27
+ from aiogram.types import MessageEntity
28
+ from aiogram.utils.text_decorations import html_decoration
29
+
30
+ Degrade = Callable[[str, str], None]
31
+
32
+
33
+ class MarkupPolicy(StrEnum):
34
+ """Что делать с разметкой по дороге в MAX."""
35
+
36
+ # Перевести в html, понятный MAX. По умолчанию.
37
+ CONVERT = "convert"
38
+
39
+ # Отдать текст как есть, только проставить format. Для тех, кто
40
+ # форматирует сам и не хочет посредника.
41
+ RAW = "raw"
42
+
43
+
44
+ # Проверено на живом MAX (см. README): эти теги он превращает в разметку.
45
+ # blockquote и tg-spoiler не распознаются, поэтому соответствующие entity
46
+ # отбрасываем до рендера, а не после.
47
+ _SUPPORTED_ENTITIES = frozenset(
48
+ {
49
+ "bold",
50
+ "italic",
51
+ "underline",
52
+ "strikethrough",
53
+ "code",
54
+ "pre",
55
+ "text_link",
56
+ "url",
57
+ "text_mention",
58
+ "mention",
59
+ }
60
+ )
61
+
62
+ _ENTITY_LOSS = {
63
+ "spoiler": "у MAX нет спойлера",
64
+ "blockquote": "у MAX нет цитаты",
65
+ "expandable_blockquote": "у MAX нет цитаты",
66
+ "custom_emoji": "у MAX нет кастомных эмодзи",
67
+ }
68
+
69
+
70
+ def entities_to_html(
71
+ text: str, entities: Iterable[MessageEntity] | None, degrade: Degrade
72
+ ) -> str:
73
+ """``text`` + ``entities`` → html для MAX.
74
+
75
+ Неподдерживаемые entity выбрасываются до рендера: их текст остаётся,
76
+ оформление теряется. Рендер — ``html_decoration`` из aiogram, там уже
77
+ решён вопрос смещений в UTF-16.
78
+ """
79
+ kept: list[MessageEntity] = []
80
+ for entity in entities or []:
81
+ loss = _ENTITY_LOSS.get(entity.type)
82
+ if loss is not None:
83
+ degrade(f"entity {entity.type}", f"{loss}, отправлено без оформления")
84
+ continue
85
+ if entity.type in _SUPPORTED_ENTITIES:
86
+ kept.append(entity)
87
+ else:
88
+ degrade(f"entity {entity.type}", "нет аналога в MAX")
89
+ return html_decoration.unparse(text, kept)
90
+
91
+
92
+ def markdown_to_html(text: str, degrade: Degrade) -> str:
93
+ """MarkdownV2 (и legacy Markdown) → html для MAX."""
94
+ return _Scanner(text, degrade).run()
95
+
96
+
97
+ _ESCAPABLE = set("_*[]()~`>#+-=|{}.!\\")
98
+
99
+ # Двухсимвольные раньше односимвольных: иначе ``__`` съедается как два ``_``.
100
+ _DELIMITERS: tuple[tuple[str, str], ...] = (
101
+ ("||", "spoiler"),
102
+ ("__", "u"),
103
+ ("*", "b"),
104
+ ("_", "i"),
105
+ ("~", "s"),
106
+ )
107
+
108
+
109
+ def _escape(chunk: str) -> str:
110
+ return chunk.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
111
+
112
+
113
+ class _Scanner:
114
+ """Однопроходный разбор MarkdownV2 с выдачей html.
115
+
116
+ Промежуточных entity нет намеренно: они считаются в смещениях UTF-16, а
117
+ здесь эта бухгалтерия не нужна ни для чего.
118
+
119
+ Порядок важен: сначала снимается экранирование и вырезаются кодовые
120
+ спаны (внутри них разметки нет), и только потом разбираются эмфазы.
121
+ """
122
+
123
+ def __init__(self, text: str, degrade: Degrade) -> None:
124
+ self._text = text
125
+ self._degrade = degrade
126
+ self._out: list[str] = []
127
+ self._open: list[str] = []
128
+ self._pos = 0
129
+
130
+ def run(self) -> str:
131
+ text = self._text
132
+ while self._pos < len(text):
133
+ if self._take_escape():
134
+ continue
135
+ if self._take_pre():
136
+ continue
137
+ if self._take_code():
138
+ continue
139
+ if self._take_link():
140
+ continue
141
+ if self._take_delimiter():
142
+ continue
143
+ if self._take_quote_marker():
144
+ continue
145
+ self._out.append(_escape(text[self._pos]))
146
+ self._pos += 1
147
+
148
+ # Незакрытый разделитель — ошибка в исходном тексте: Telegram на нём
149
+ # отвечает 400. Закрываем сами, потому что оборванный тег испортил бы
150
+ # всё сообщение целиком.
151
+ while self._open:
152
+ self._out.append(f"</{self._open.pop()}>")
153
+ return "".join(self._out)
154
+
155
+ def _take_escape(self) -> bool:
156
+ text = self._text
157
+ if text[self._pos] != "\\" or self._pos + 1 >= len(text):
158
+ return False
159
+ nxt = text[self._pos + 1]
160
+ if nxt not in _ESCAPABLE:
161
+ return False
162
+ self._out.append(_escape(nxt))
163
+ self._pos += 2
164
+ return True
165
+
166
+ def _take_pre(self) -> bool:
167
+ text = self._text
168
+ if not text.startswith("```", self._pos):
169
+ return False
170
+ end = text.find("```", self._pos + 3)
171
+ if end == -1:
172
+ return False
173
+ body = text[self._pos + 3 : end]
174
+ # Первая строка после ``` — либо язык (в MAX ему места нет), либо
175
+ # пусто. В обоих случаях в тело кода она не входит.
176
+ if "\n" in body:
177
+ first, rest = body.split("\n", 1)
178
+ head = first.strip()
179
+ if not head or " " not in head:
180
+ body = rest
181
+ self._out.append(f"<pre>{_escape(body)}</pre>")
182
+ self._pos = end + 3
183
+ return True
184
+
185
+ def _take_code(self) -> bool:
186
+ text = self._text
187
+ if text[self._pos] != "`":
188
+ return False
189
+ end = text.find("`", self._pos + 1)
190
+ if end == -1:
191
+ return False
192
+ body = text[self._pos + 1 : end].replace("\\`", "`").replace("\\\\", "\\")
193
+ self._out.append(f"<code>{_escape(body)}</code>")
194
+ self._pos = end + 1
195
+ return True
196
+
197
+ def _take_link(self) -> bool:
198
+ text = self._text
199
+ emoji = text.startswith("![", self._pos)
200
+ start = self._pos + 2 if emoji else self._pos + 1
201
+ if not emoji and text[self._pos] != "[":
202
+ return False
203
+ close = text.find("](", start)
204
+ if close == -1:
205
+ return False
206
+ end = text.find(")", close + 2)
207
+ if end == -1:
208
+ return False
209
+ label = text[start:close]
210
+ url = text[close + 2 : end]
211
+ self._pos = end + 1
212
+ if emoji or url.startswith("tg://emoji"):
213
+ self._degrade(
214
+ "custom_emoji", "у MAX нет кастомных эмодзи, оставлен только текст"
215
+ )
216
+ self._out.append(_escape(label))
217
+ return True
218
+ self._out.append(f'<a href="{_escape(url)}">{_escape(label)}</a>')
219
+ return True
220
+
221
+ def _take_delimiter(self) -> bool:
222
+ text = self._text
223
+ for delim, tag in _DELIMITERS:
224
+ if not text.startswith(delim, self._pos):
225
+ continue
226
+ self._pos += len(delim)
227
+ if tag == "spoiler":
228
+ # Открывающий и закрывающий || просто исчезают: текст между
229
+ # ними остаётся видимым и неформатированным.
230
+ self._degrade("spoiler", "у MAX нет спойлера, текст оставлен открытым")
231
+ return True
232
+ if self._open and self._open[-1] == tag:
233
+ self._out.append(f"</{self._open.pop()}>")
234
+ else:
235
+ self._open.append(tag)
236
+ self._out.append(f"<{tag}>")
237
+ return True
238
+ return False
239
+
240
+ def _take_quote_marker(self) -> bool:
241
+ text = self._text
242
+ if text[self._pos] != ">":
243
+ return False
244
+ at_line_start = self._pos == 0 or text[self._pos - 1] == "\n"
245
+ if not at_line_start:
246
+ return False
247
+ self._degrade("blockquote", "у MAX нет цитаты, отправлено обычным текстом")
248
+ self._pos += 1
249
+ if self._pos < len(text) and text[self._pos] == " ":
250
+ self._pos += 1
251
+ return True