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.
- aiogram_max/__init__.py +39 -0
- aiogram_max/bot.py +75 -0
- aiogram_max/converters.py +279 -0
- aiogram_max/errors.py +44 -0
- aiogram_max/markup.py +251 -0
- aiogram_max/methods/__init__.py +12 -0
- aiogram_max/methods/base.py +73 -0
- aiogram_max/methods/chats.py +160 -0
- aiogram_max/methods/media.py +313 -0
- aiogram_max/methods/settings.py +86 -0
- aiogram_max/mids.py +44 -0
- aiogram_max/py.typed +0 -0
- aiogram_max/session.py +538 -0
- aiogram_max/uploads.py +123 -0
- aiogram_max-1.1.1.dist-info/METADATA +256 -0
- aiogram_max-1.1.1.dist-info/RECORD +18 -0
- aiogram_max-1.1.1.dist-info/WHEEL +4 -0
- aiogram_max-1.1.1.dist-info/licenses/LICENSE +21 -0
aiogram_max/__init__.py
ADDED
|
@@ -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("&", "&").replace("<", "<").replace(">", ">")
|
|
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
|