maxapi-lite 0.2.0__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.
- maxapi_lite/__init__.py +42 -0
- maxapi_lite/bot.py +453 -0
- maxapi_lite/client.py +90 -0
- maxapi_lite/enums.py +23 -0
- maxapi_lite/exceptions.py +19 -0
- maxapi_lite/py.typed +0 -0
- maxapi_lite/types.py +64 -0
- maxapi_lite-0.2.0.dist-info/METADATA +144 -0
- maxapi_lite-0.2.0.dist-info/RECORD +11 -0
- maxapi_lite-0.2.0.dist-info/WHEEL +4 -0
- maxapi_lite-0.2.0.dist-info/licenses/LICENSE +21 -0
maxapi_lite/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
maxapi_lite - легковесный асинхронный клиент MAX Bot API, совместимый с aiohttp 3.8.
|
|
3
|
+
|
|
4
|
+
Позволяет использовать MAX API в проектах, привязанных к старому aiohttp
|
|
5
|
+
(например, aiogram 2.x требует aiohttp<3.9, а официальный maxapi - aiohttp>=3.12).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .bot import Bot
|
|
9
|
+
from .enums import ChatPermission, ParseMode
|
|
10
|
+
from .exceptions import InvalidToken, MaxApiError
|
|
11
|
+
from .types import (
|
|
12
|
+
Attachment,
|
|
13
|
+
ButtonsPayload,
|
|
14
|
+
CallbackButton,
|
|
15
|
+
InputMedia,
|
|
16
|
+
LinkButton,
|
|
17
|
+
MessageButton,
|
|
18
|
+
RequestContactButton,
|
|
19
|
+
RequestGeoLocationButton,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
__version__ = "0.2.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
# Main class
|
|
26
|
+
"Bot",
|
|
27
|
+
# Types
|
|
28
|
+
"Attachment",
|
|
29
|
+
"ButtonsPayload",
|
|
30
|
+
"CallbackButton",
|
|
31
|
+
"LinkButton",
|
|
32
|
+
"MessageButton",
|
|
33
|
+
"RequestContactButton",
|
|
34
|
+
"RequestGeoLocationButton",
|
|
35
|
+
"InputMedia",
|
|
36
|
+
# Enums
|
|
37
|
+
"ParseMode",
|
|
38
|
+
"ChatPermission",
|
|
39
|
+
# Exceptions
|
|
40
|
+
"MaxApiError",
|
|
41
|
+
"InvalidToken",
|
|
42
|
+
]
|
maxapi_lite/bot.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Max API Lite - легковесный клиент для Max API
|
|
3
|
+
Совместим с aiohttp 3.8.4
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
from .client import MaxApiClient
|
|
13
|
+
from .enums import ParseMode
|
|
14
|
+
from .exceptions import MaxApiError
|
|
15
|
+
from .types import Attachment
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Bot:
|
|
19
|
+
"""
|
|
20
|
+
Основной класс бота для работы с Max API
|
|
21
|
+
Реализует только необходимые методы для совместимости с aiohttp 3.8.4
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, token: Optional[str] = None, parse_mode: Optional[ParseMode] = None):
|
|
25
|
+
"""
|
|
26
|
+
Инициализация бота
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
token: Токен бота Max API
|
|
30
|
+
parse_mode: Режим парсинга по умолчанию (HTML/MARKDOWN)
|
|
31
|
+
"""
|
|
32
|
+
if token is None:
|
|
33
|
+
token = os.environ.get("MAX_BOT_TOKEN")
|
|
34
|
+
if token is None:
|
|
35
|
+
raise ValueError("Token must be provided or set in MAX_BOT_TOKEN environment variable")
|
|
36
|
+
|
|
37
|
+
self.client = MaxApiClient(token)
|
|
38
|
+
self.parse_mode = parse_mode
|
|
39
|
+
self._me: Optional[Dict[str, Any]] = None
|
|
40
|
+
|
|
41
|
+
async def close_session(self):
|
|
42
|
+
"""Закрыть HTTP сессию"""
|
|
43
|
+
await self.client.close()
|
|
44
|
+
|
|
45
|
+
async def get_me(self) -> Dict[str, Any]:
|
|
46
|
+
"""
|
|
47
|
+
Получить информацию о текущем боте
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Dict с информацией о боте (user_id, name, username, etc)
|
|
51
|
+
"""
|
|
52
|
+
if self._me is None:
|
|
53
|
+
self._me = await self.client.request("GET", "/me")
|
|
54
|
+
return self._me
|
|
55
|
+
|
|
56
|
+
async def get_updates(self, limit: int = 100, timeout: int = 30, marker: Optional[int] = None) -> Dict[str, Any]:
|
|
57
|
+
"""
|
|
58
|
+
Получить обновления бота (long polling)
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
limit: Максимальное количество обновлений
|
|
62
|
+
timeout: Таймаут ожидания новых обновлений
|
|
63
|
+
marker: Маркер последнего обработанного обновления
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Dict с полем "updates" - список обновлений
|
|
67
|
+
"""
|
|
68
|
+
params = {"limit": limit, "timeout": timeout}
|
|
69
|
+
if marker is not None:
|
|
70
|
+
params["marker"] = marker
|
|
71
|
+
|
|
72
|
+
return await self.client.request("GET", "/updates", params=params)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _serialize_attachments(
|
|
76
|
+
attachments: Optional[List[Union[Attachment, Dict[str, Any]]]],
|
|
77
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
78
|
+
if attachments is None:
|
|
79
|
+
return None
|
|
80
|
+
result: List[Dict[str, Any]] = []
|
|
81
|
+
for att in attachments:
|
|
82
|
+
if hasattr(att, "model_dump"):
|
|
83
|
+
result.append(att.model_dump())
|
|
84
|
+
elif isinstance(att, dict):
|
|
85
|
+
result.append(att)
|
|
86
|
+
else:
|
|
87
|
+
result.append(att)
|
|
88
|
+
return result
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def extract_message_id(response: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
92
|
+
if not isinstance(response, dict):
|
|
93
|
+
return None
|
|
94
|
+
message = response.get("message") or {}
|
|
95
|
+
body = message.get("body") or {}
|
|
96
|
+
mid = body.get("mid")
|
|
97
|
+
if not mid:
|
|
98
|
+
return None
|
|
99
|
+
return str(mid)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def build_link_button_attachment(text: str, url: str) -> Dict[str, Any]:
|
|
103
|
+
return {
|
|
104
|
+
"type": "inline_keyboard",
|
|
105
|
+
"payload": {
|
|
106
|
+
"buttons": [
|
|
107
|
+
[
|
|
108
|
+
{
|
|
109
|
+
"type": "link",
|
|
110
|
+
"text": text,
|
|
111
|
+
"url": url,
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _media_mapping(media_type: str) -> tuple[str, str]:
|
|
120
|
+
return {
|
|
121
|
+
"photo": ("image", "image"),
|
|
122
|
+
"animation": ("image", "image"),
|
|
123
|
+
"video": ("video", "video"),
|
|
124
|
+
"document": ("file", "file"),
|
|
125
|
+
"file": ("file", "file"),
|
|
126
|
+
"image": ("image", "image"),
|
|
127
|
+
}.get(media_type, ("file", "file"))
|
|
128
|
+
|
|
129
|
+
async def upload_file(self, file_path: str, upload_type: str) -> Dict[str, Any]:
|
|
130
|
+
if not file_path or not os.path.isfile(file_path):
|
|
131
|
+
raise MaxApiError(f"File not found: {file_path}", 400)
|
|
132
|
+
|
|
133
|
+
init_resp = await self.client.request("POST", "/uploads", params={"type": upload_type})
|
|
134
|
+
upload_url = init_resp.get("url")
|
|
135
|
+
if not upload_url:
|
|
136
|
+
raise MaxApiError(f"MAX upload init response has no url: {init_resp}", 500)
|
|
137
|
+
|
|
138
|
+
content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
|
139
|
+
async with aiohttp.ClientSession() as session:
|
|
140
|
+
with open(file_path, "rb") as media_file:
|
|
141
|
+
form = aiohttp.FormData()
|
|
142
|
+
form.add_field(
|
|
143
|
+
"data",
|
|
144
|
+
media_file,
|
|
145
|
+
filename=os.path.basename(file_path),
|
|
146
|
+
content_type=content_type,
|
|
147
|
+
)
|
|
148
|
+
async with session.post(upload_url, data=form) as response:
|
|
149
|
+
if response.status >= 400:
|
|
150
|
+
text = await response.text()
|
|
151
|
+
raise MaxApiError(f"MAX upload failed [{response.status}]: {text}", response.status)
|
|
152
|
+
try:
|
|
153
|
+
return await response.json()
|
|
154
|
+
except Exception:
|
|
155
|
+
return {}
|
|
156
|
+
|
|
157
|
+
async def create_media_attachment(
|
|
158
|
+
self,
|
|
159
|
+
media_type: str,
|
|
160
|
+
*,
|
|
161
|
+
file_path: Optional[str] = None,
|
|
162
|
+
url: Optional[str] = None,
|
|
163
|
+
) -> Optional[Dict[str, Any]]:
|
|
164
|
+
upload_type, attachment_type = self._media_mapping(media_type)
|
|
165
|
+
if url:
|
|
166
|
+
return {"type": attachment_type, "payload": {"url": url}}
|
|
167
|
+
if not file_path:
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
upload_resp = await self.upload_file(file_path, upload_type)
|
|
171
|
+
if upload_type == "image":
|
|
172
|
+
photos = upload_resp.get("photos") or {}
|
|
173
|
+
first_photo = next(iter(photos.values()), None) if isinstance(photos, dict) else None
|
|
174
|
+
if not isinstance(first_photo, dict):
|
|
175
|
+
raise MaxApiError(f"MAX image upload payload is invalid: {upload_resp}", 500)
|
|
176
|
+
payload = first_photo
|
|
177
|
+
else:
|
|
178
|
+
payload = upload_resp if isinstance(upload_resp, dict) else {}
|
|
179
|
+
if not payload.get("token"):
|
|
180
|
+
raise MaxApiError(f"MAX upload payload is invalid: {upload_resp}", 500)
|
|
181
|
+
return {"type": attachment_type, "payload": payload}
|
|
182
|
+
|
|
183
|
+
async def send_message(
|
|
184
|
+
self,
|
|
185
|
+
chat_id: Optional[int] = None,
|
|
186
|
+
user_id: Optional[int] = None,
|
|
187
|
+
text: Optional[str] = None,
|
|
188
|
+
attachments: Optional[List[Union[Attachment, Dict[str, Any]]]] = None,
|
|
189
|
+
format: Optional[str] = None,
|
|
190
|
+
notify: Optional[bool] = None,
|
|
191
|
+
**kwargs,
|
|
192
|
+
) -> Dict[str, Any]:
|
|
193
|
+
"""
|
|
194
|
+
Отправить сообщение
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
chat_id: ID чата (для групповых чатов)
|
|
198
|
+
user_id: ID пользователя (для личных сообщений)
|
|
199
|
+
text: Текст сообщения
|
|
200
|
+
attachments: Вложения (кнопки, медиа)
|
|
201
|
+
format: Формат текста (html/markdown)
|
|
202
|
+
notify: Отправлять уведомление
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Dict с отправленным сообщением
|
|
206
|
+
"""
|
|
207
|
+
# Формируем тело запроса
|
|
208
|
+
body: Dict[str, Any] = {}
|
|
209
|
+
params: Dict[str, Any] = {}
|
|
210
|
+
|
|
211
|
+
if chat_id is not None:
|
|
212
|
+
params["chat_id"] = chat_id
|
|
213
|
+
elif user_id is not None:
|
|
214
|
+
params["user_id"] = user_id
|
|
215
|
+
else:
|
|
216
|
+
raise ValueError("Either chat_id or user_id must be provided")
|
|
217
|
+
|
|
218
|
+
if text:
|
|
219
|
+
body["text"] = text
|
|
220
|
+
|
|
221
|
+
serialized_attachments = self._serialize_attachments(attachments)
|
|
222
|
+
if serialized_attachments is not None:
|
|
223
|
+
body["attachments"] = serialized_attachments
|
|
224
|
+
|
|
225
|
+
# Используем format или parse_mode
|
|
226
|
+
if format:
|
|
227
|
+
body["format"] = format
|
|
228
|
+
elif self.parse_mode:
|
|
229
|
+
body["format"] = self.parse_mode.value
|
|
230
|
+
|
|
231
|
+
if notify is not None:
|
|
232
|
+
body["notify"] = notify
|
|
233
|
+
|
|
234
|
+
return await self.client.request("POST", "/messages", params=params, json=body)
|
|
235
|
+
|
|
236
|
+
async def send_message_with_media(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
chat_id: int,
|
|
240
|
+
text: Optional[str] = None,
|
|
241
|
+
media_type: Optional[str] = None,
|
|
242
|
+
file_path: Optional[str] = None,
|
|
243
|
+
media_url: Optional[str] = None,
|
|
244
|
+
attachments: Optional[List[Union[Attachment, Dict[str, Any]]]] = None,
|
|
245
|
+
format: Optional[str] = None,
|
|
246
|
+
notify: Optional[bool] = None,
|
|
247
|
+
) -> Dict[str, Any]:
|
|
248
|
+
send_attachments: List[Union[Attachment, Dict[str, Any]]] = []
|
|
249
|
+
if attachments:
|
|
250
|
+
send_attachments.extend(attachments)
|
|
251
|
+
if media_type and (file_path or media_url):
|
|
252
|
+
media_attachment = await self.create_media_attachment(media_type, file_path=file_path, url=media_url)
|
|
253
|
+
if media_attachment:
|
|
254
|
+
send_attachments.append(media_attachment)
|
|
255
|
+
return await self.send_message(
|
|
256
|
+
chat_id=chat_id,
|
|
257
|
+
text=text,
|
|
258
|
+
attachments=send_attachments or None,
|
|
259
|
+
format=format,
|
|
260
|
+
notify=notify,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
async def edit_message(
|
|
264
|
+
self,
|
|
265
|
+
message_id: str,
|
|
266
|
+
text: Optional[str] = None,
|
|
267
|
+
attachments: Optional[List[Union[Attachment, Dict[str, Any]]]] = None,
|
|
268
|
+
format: Optional[str] = None,
|
|
269
|
+
**kwargs,
|
|
270
|
+
) -> Dict[str, Any]:
|
|
271
|
+
"""
|
|
272
|
+
Редактировать сообщение
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
message_id: ID сообщения для редактирования
|
|
276
|
+
text: Новый текст
|
|
277
|
+
attachments: Новые вложения
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Dict с отредактированным сообщением
|
|
281
|
+
"""
|
|
282
|
+
body: Dict[str, Any] = {}
|
|
283
|
+
params: Dict[str, Any] = {"message_id": message_id}
|
|
284
|
+
|
|
285
|
+
if text is not None:
|
|
286
|
+
body["text"] = text
|
|
287
|
+
|
|
288
|
+
serialized_attachments = self._serialize_attachments(attachments)
|
|
289
|
+
if serialized_attachments is not None:
|
|
290
|
+
body["attachments"] = serialized_attachments
|
|
291
|
+
|
|
292
|
+
# Preserve text formatting on edits the same way as in send_message.
|
|
293
|
+
# Format only applies to text, so attachment-only edits leave the text untouched.
|
|
294
|
+
if text is not None:
|
|
295
|
+
if format:
|
|
296
|
+
body["format"] = format
|
|
297
|
+
elif self.parse_mode:
|
|
298
|
+
body["format"] = self.parse_mode.value
|
|
299
|
+
|
|
300
|
+
return await self.client.request("PUT", "/messages", params=params, json=body)
|
|
301
|
+
|
|
302
|
+
async def edit_message_with_media(
|
|
303
|
+
self,
|
|
304
|
+
*,
|
|
305
|
+
message_id: str,
|
|
306
|
+
text: Optional[str] = None,
|
|
307
|
+
media_type: Optional[str] = None,
|
|
308
|
+
file_path: Optional[str] = None,
|
|
309
|
+
media_url: Optional[str] = None,
|
|
310
|
+
attachments: Optional[List[Union[Attachment, Dict[str, Any]]]] = None,
|
|
311
|
+
format: Optional[str] = None,
|
|
312
|
+
) -> Dict[str, Any]:
|
|
313
|
+
edit_attachments: List[Union[Attachment, Dict[str, Any]]] = []
|
|
314
|
+
if attachments:
|
|
315
|
+
edit_attachments.extend(attachments)
|
|
316
|
+
if media_type and (file_path or media_url):
|
|
317
|
+
media_attachment = await self.create_media_attachment(media_type, file_path=file_path, url=media_url)
|
|
318
|
+
if media_attachment:
|
|
319
|
+
edit_attachments.append(media_attachment)
|
|
320
|
+
return await self.edit_message(
|
|
321
|
+
message_id=message_id,
|
|
322
|
+
text=text,
|
|
323
|
+
attachments=edit_attachments or [],
|
|
324
|
+
format=format,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
async def pin_chat_message(self, chat_id: int, message_id: str, notify: bool = True) -> Dict[str, Any]:
|
|
328
|
+
"""
|
|
329
|
+
Закрепить сообщение в чате/канале.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
chat_id: ID чата
|
|
333
|
+
message_id: ID сообщения (mid.*)
|
|
334
|
+
notify: Показывать уведомление участникам
|
|
335
|
+
"""
|
|
336
|
+
body: Dict[str, Any] = {"message_id": message_id}
|
|
337
|
+
if notify is not None:
|
|
338
|
+
body["notify"] = notify
|
|
339
|
+
return await self.client.request("PUT", f"/chats/{chat_id}/pin", json=body)
|
|
340
|
+
|
|
341
|
+
async def pin_from_response(
|
|
342
|
+
self, chat_id: int, response: Dict[str, Any], notify: bool = True
|
|
343
|
+
) -> Optional[Dict[str, Any]]:
|
|
344
|
+
message_id = self.extract_message_id(response)
|
|
345
|
+
if not message_id:
|
|
346
|
+
return None
|
|
347
|
+
return await self.pin_chat_message(chat_id=chat_id, message_id=message_id, notify=notify)
|
|
348
|
+
|
|
349
|
+
async def get_message(self, message_id: str) -> Dict[str, Any]:
|
|
350
|
+
"""Получить сообщение (текст и вложения) по ID."""
|
|
351
|
+
return await self.client.request("GET", f"/messages/{message_id}")
|
|
352
|
+
|
|
353
|
+
async def unpin_chat_message(self, chat_id: int) -> Dict[str, Any]:
|
|
354
|
+
"""Remove the pinned message or channel post from a MAX chat."""
|
|
355
|
+
return await self.client.request("DELETE", f"/chats/{chat_id}/pin")
|
|
356
|
+
|
|
357
|
+
async def delete_message(self, message_id: str) -> Dict[str, Any]:
|
|
358
|
+
"""
|
|
359
|
+
Удалить сообщение
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
message_id: ID сообщения
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Dict с результатом удаления
|
|
366
|
+
"""
|
|
367
|
+
return await self.client.request("DELETE", f"/messages/{message_id}")
|
|
368
|
+
|
|
369
|
+
async def send_callback(self, callback_id: str, notification: Optional[str] = None) -> Dict[str, Any]:
|
|
370
|
+
"""
|
|
371
|
+
Ответить на callback query
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
callback_id: ID callback query
|
|
375
|
+
notification: Текст уведомления для пользователя
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
Dict с результатом
|
|
379
|
+
"""
|
|
380
|
+
body: Dict[str, Any] = {"callback_id": callback_id}
|
|
381
|
+
|
|
382
|
+
if notification:
|
|
383
|
+
body["notification"] = notification
|
|
384
|
+
|
|
385
|
+
return await self.client.request("POST", "/answers", json=body)
|
|
386
|
+
|
|
387
|
+
async def get_chat_by_id(self, id: int) -> Dict[str, Any]:
|
|
388
|
+
"""
|
|
389
|
+
Получить информацию о чате
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
id: ID чата
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
Dict с информацией о чате
|
|
396
|
+
"""
|
|
397
|
+
return await self.client.request("GET", f"/chats/{id}")
|
|
398
|
+
|
|
399
|
+
async def get_chat_member(self, chat_id: int, user_id: int) -> Optional[Dict[str, Any]]:
|
|
400
|
+
"""
|
|
401
|
+
Получить информацию об участнике чата с его правами
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
chat_id: ID чата
|
|
405
|
+
user_id: ID пользователя
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
Dict с информацией об участнике (включая permissions) или None
|
|
409
|
+
"""
|
|
410
|
+
# Max API возвращает список участников
|
|
411
|
+
result = await self.client.request("GET", f"/chats/{chat_id}/members", params={"user_ids": [user_id]})
|
|
412
|
+
|
|
413
|
+
members = result.get("members", [])
|
|
414
|
+
if members:
|
|
415
|
+
return members[0]
|
|
416
|
+
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
async def get_list_admin_chat(self, chat_id: int) -> Dict[str, Any]:
|
|
420
|
+
"""
|
|
421
|
+
Получить список администраторов чата
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
chat_id: ID чата
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Dict со списком администраторов в поле "members"
|
|
428
|
+
"""
|
|
429
|
+
return await self.client.request("GET", f"/chats/{chat_id}/members/admins")
|
|
430
|
+
|
|
431
|
+
async def get_chat_members_batch(self, chat_id: int, user_ids: list[int]) -> list[int]:
|
|
432
|
+
"""
|
|
433
|
+
Проверить список пользователей на членство в чате.
|
|
434
|
+
Возвращает список user_id которые состоят в чате.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
chat_id: ID чата
|
|
438
|
+
user_ids: Список ID пользователей для проверки (максимум 100 за раз)
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
List с user_id участников чата
|
|
442
|
+
"""
|
|
443
|
+
if not user_ids:
|
|
444
|
+
return []
|
|
445
|
+
|
|
446
|
+
ids_str = ','.join(str(uid) for uid in user_ids)
|
|
447
|
+
result = await self.client.request(
|
|
448
|
+
"GET",
|
|
449
|
+
f"/chats/{chat_id}/members",
|
|
450
|
+
params={"user_ids": ids_str}
|
|
451
|
+
)
|
|
452
|
+
members = result.get("members", [])
|
|
453
|
+
return [m.get("user_id") for m in members if m.get("user_id")]
|
maxapi_lite/client.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP клиент для Max API Lite
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from typing import Optional, Dict, Any
|
|
7
|
+
from .exceptions import MaxApiError, InvalidToken
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MaxApiClient:
|
|
11
|
+
"""Легковесный HTTP клиент для Max API, совместимый с aiohttp 3.8.4"""
|
|
12
|
+
|
|
13
|
+
BASE_URL = "https://platform-api.max.ru"
|
|
14
|
+
|
|
15
|
+
def __init__(self, token: str):
|
|
16
|
+
"""
|
|
17
|
+
Инициализация клиента
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
token: Токен бота Max API
|
|
21
|
+
"""
|
|
22
|
+
if not token:
|
|
23
|
+
raise InvalidToken("Token cannot be empty")
|
|
24
|
+
|
|
25
|
+
self.token = token
|
|
26
|
+
self.headers = {"Authorization": token}
|
|
27
|
+
self._session: Optional[aiohttp.ClientSession] = None
|
|
28
|
+
|
|
29
|
+
async def _get_session(self) -> aiohttp.ClientSession:
|
|
30
|
+
"""Получить или создать aiohttp сессию"""
|
|
31
|
+
if self._session is None or self._session.closed:
|
|
32
|
+
self._session = aiohttp.ClientSession(headers=self.headers)
|
|
33
|
+
return self._session
|
|
34
|
+
|
|
35
|
+
async def close(self):
|
|
36
|
+
"""Закрыть HTTP сессию"""
|
|
37
|
+
if self._session and not self._session.closed:
|
|
38
|
+
await self._session.close()
|
|
39
|
+
self._session = None
|
|
40
|
+
|
|
41
|
+
async def request(
|
|
42
|
+
self,
|
|
43
|
+
method: str,
|
|
44
|
+
endpoint: str,
|
|
45
|
+
params: Optional[Dict[str, Any]] = None,
|
|
46
|
+
json: Optional[Dict[str, Any]] = None,
|
|
47
|
+
**kwargs,
|
|
48
|
+
) -> Dict[str, Any]:
|
|
49
|
+
"""
|
|
50
|
+
Выполнить HTTP запрос к Max API
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
method: HTTP метод (GET, POST, PUT, DELETE)
|
|
54
|
+
endpoint: API endpoint (начинается с /)
|
|
55
|
+
params: Query параметры
|
|
56
|
+
json: JSON body запроса
|
|
57
|
+
**kwargs: Дополнительные параметры для aiohttp
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Dict с ответом API
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
InvalidToken: При ошибке авторизации (401)
|
|
64
|
+
MaxApiError: При других ошибках API
|
|
65
|
+
"""
|
|
66
|
+
session = await self._get_session()
|
|
67
|
+
url = f"{self.BASE_URL}{endpoint}"
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
async with session.request(method=method, url=url, params=params, json=json, **kwargs) as response:
|
|
71
|
+
# Читаем ответ
|
|
72
|
+
try:
|
|
73
|
+
data = await response.json()
|
|
74
|
+
except Exception:
|
|
75
|
+
# Если не JSON, читаем как текст
|
|
76
|
+
text = await response.text()
|
|
77
|
+
data = {"error": text}
|
|
78
|
+
|
|
79
|
+
# Проверяем статус
|
|
80
|
+
if response.status == 401:
|
|
81
|
+
raise InvalidToken("Invalid or expired token")
|
|
82
|
+
|
|
83
|
+
if response.status >= 400:
|
|
84
|
+
error_message = data.get("error", data.get("message", "Unknown error"))
|
|
85
|
+
raise MaxApiError(error_message, response.status)
|
|
86
|
+
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
except aiohttp.ClientError as e:
|
|
90
|
+
raise MaxApiError(f"Connection error: {e}", 500)
|
maxapi_lite/enums.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enums для Max API Lite
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ParseMode(str, Enum):
|
|
9
|
+
"""Режим парсинга текста"""
|
|
10
|
+
|
|
11
|
+
HTML = "html"
|
|
12
|
+
MARKDOWN = "markdown"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChatPermission(str, Enum):
|
|
16
|
+
"""Права участника чата"""
|
|
17
|
+
|
|
18
|
+
# Основные права из Max API
|
|
19
|
+
READ_ALL_MESSAGES = "read_all_messages"
|
|
20
|
+
WRITE = "write"
|
|
21
|
+
ADD_REMOVE_MEMBERS = "add_remove_members"
|
|
22
|
+
ADD_ADMINS = "add_admins"
|
|
23
|
+
CHANGE_CHAT_INFO = "change_chat_info"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Исключения для Max API Lite
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MaxApiError(Exception):
|
|
7
|
+
"""Базовое исключение Max API"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str = "Unknown error", code: int = 500):
|
|
10
|
+
self.message = message
|
|
11
|
+
self.code = code
|
|
12
|
+
super().__init__(f"[{code}] {message}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvalidToken(MaxApiError):
|
|
16
|
+
"""Неверный токен авторизации"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str = "Invalid token"):
|
|
19
|
+
super().__init__(message, 401)
|
maxapi_lite/py.typed
ADDED
|
File without changes
|
maxapi_lite/types.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Типы данных для Max API Lite
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from typing import Optional, List, Any, Union, Dict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Кнопки
|
|
10
|
+
class CallbackButton(BaseModel):
|
|
11
|
+
"""Кнопка с callback"""
|
|
12
|
+
|
|
13
|
+
text: str
|
|
14
|
+
payload: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LinkButton(BaseModel):
|
|
18
|
+
"""Кнопка со ссылкой"""
|
|
19
|
+
|
|
20
|
+
text: str
|
|
21
|
+
url: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MessageButton(BaseModel):
|
|
25
|
+
"""Кнопка с текстовым сообщением"""
|
|
26
|
+
|
|
27
|
+
text: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RequestContactButton(BaseModel):
|
|
31
|
+
"""Кнопка запроса контакта"""
|
|
32
|
+
|
|
33
|
+
text: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RequestGeoLocationButton(BaseModel):
|
|
37
|
+
"""Кнопка запроса геолокации"""
|
|
38
|
+
|
|
39
|
+
text: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
ButtonType = Union[CallbackButton, LinkButton, MessageButton, RequestContactButton, RequestGeoLocationButton]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ButtonsPayload(BaseModel):
|
|
46
|
+
"""Payload для кнопок"""
|
|
47
|
+
|
|
48
|
+
buttons: List[List[ButtonType]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Attachment(BaseModel):
|
|
52
|
+
"""Вложение к сообщению"""
|
|
53
|
+
|
|
54
|
+
type: str
|
|
55
|
+
payload: Union[ButtonsPayload, Dict[str, Any], Any]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class InputMedia(BaseModel):
|
|
59
|
+
"""Медиа для загрузки"""
|
|
60
|
+
|
|
61
|
+
type: str = "photo"
|
|
62
|
+
url: Optional[str] = None
|
|
63
|
+
token: Optional[str] = None
|
|
64
|
+
file_path: Optional[str] = None
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: maxapi-lite
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Lightweight async client for the MAX messenger Bot API, compatible with aiohttp 3.8
|
|
5
|
+
Project-URL: Homepage, https://github.com/kiriyk/maxapi-lite
|
|
6
|
+
Project-URL: Source, https://github.com/kiriyk/maxapi-lite
|
|
7
|
+
Project-URL: Issues, https://github.com/kiriyk/maxapi-lite/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/kiriyk/maxapi-lite/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Kirill
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: aiohttp,asyncio,bot,bot-api,max,max.ru,messenger
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Natural Language :: Russian
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Communications :: Chat
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.9
|
|
29
|
+
Requires-Dist: aiohttp>=3.8
|
|
30
|
+
Requires-Dist: pydantic>=2
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'test'
|
|
33
|
+
Requires-Dist: pytest>=7; extra == 'test'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# maxapi-lite
|
|
37
|
+
|
|
38
|
+
[](https://pypi.org/project/maxapi-lite/)
|
|
39
|
+
[](https://pypi.org/project/maxapi-lite/)
|
|
40
|
+
[](https://github.com/kiriyk/maxapi-lite/actions/workflows/ci.yml)
|
|
41
|
+
|
|
42
|
+
Легковесный асинхронный клиент [MAX Bot API](https://dev.max.ru/docs-api), совместимый с `aiohttp 3.8.x`.
|
|
43
|
+
|
|
44
|
+
Пакет нужен, когда в проекте уже зафиксирован старый `aiohttp` — например, рядом живёт
|
|
45
|
+
`aiogram 2.x` (`aiohttp<3.9`), а официальный клиент `maxapi` требует `aiohttp>=3.12`.
|
|
46
|
+
Зависимости — только `aiohttp>=3.8` и `pydantic>=2`.
|
|
47
|
+
|
|
48
|
+
*Lightweight async client for the MAX messenger Bot API that works with aiohttp 3.8,
|
|
49
|
+
so it can share an environment with aiogram 2.x.*
|
|
50
|
+
|
|
51
|
+
## Установка
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install maxapi-lite
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Быстрый старт
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
from maxapi_lite import Bot, ParseMode
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def main():
|
|
65
|
+
bot = Bot(token="YOUR_MAX_TOKEN", parse_mode=ParseMode.HTML) # или переменная окружения MAX_BOT_TOKEN
|
|
66
|
+
|
|
67
|
+
# Кнопка-ссылка
|
|
68
|
+
kb_attachment = bot.build_link_button_attachment(
|
|
69
|
+
text="Открыть MiniApp",
|
|
70
|
+
url="https://example.com/app?startapp=123",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Отправка фото + кнопки
|
|
74
|
+
result = await bot.send_message_with_media(
|
|
75
|
+
chat_id=-70893181482276,
|
|
76
|
+
text="<b>Розыгрыш</b>",
|
|
77
|
+
media_type="photo",
|
|
78
|
+
file_path="banner.jpg", # или media_url="https://..."
|
|
79
|
+
attachments=[kb_attachment],
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Извлечение mid и закрепление
|
|
83
|
+
mid = bot.extract_message_id(result)
|
|
84
|
+
if mid:
|
|
85
|
+
await bot.pin_chat_message(chat_id=-70893181482276, message_id=mid)
|
|
86
|
+
|
|
87
|
+
await bot.close_session()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
asyncio.run(main())
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Long polling
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
marker = None
|
|
97
|
+
while True:
|
|
98
|
+
data = await bot.get_updates(timeout=30, marker=marker)
|
|
99
|
+
for update in data.get("updates", []):
|
|
100
|
+
...
|
|
101
|
+
marker = data.get("marker", marker)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Методы `Bot`
|
|
105
|
+
|
|
106
|
+
| Группа | Методы |
|
|
107
|
+
| --- | --- |
|
|
108
|
+
| Бот и обновления | `get_me`, `get_updates` |
|
|
109
|
+
| Сообщения | `send_message`, `edit_message`, `delete_message`, `get_message` |
|
|
110
|
+
| Медиа | `upload_file`, `create_media_attachment`, `send_message_with_media`, `edit_message_with_media` |
|
|
111
|
+
| Закрепление | `pin_chat_message`, `pin_from_response`, `unpin_chat_message` |
|
|
112
|
+
| Чаты | `get_chat_by_id`, `get_chat_member`, `get_chat_members_batch`, `get_list_admin_chat` |
|
|
113
|
+
| Callback | `send_callback` |
|
|
114
|
+
| Утилиты | `extract_message_id`, `build_link_button_attachment` |
|
|
115
|
+
|
|
116
|
+
Методы возвращают «сырые» `dict` из ответа API. Ошибки API поднимаются как `MaxApiError`
|
|
117
|
+
(с полем `code`), ошибка авторизации — как `InvalidToken`.
|
|
118
|
+
|
|
119
|
+
## Поддерживаемые `media_type`
|
|
120
|
+
|
|
121
|
+
- `photo`, `animation`, `image` → загрузка как `image`
|
|
122
|
+
- `video`
|
|
123
|
+
- `document`, `file`
|
|
124
|
+
|
|
125
|
+
## Примечания
|
|
126
|
+
|
|
127
|
+
- `chat_id` / `user_id` передаются query-параметрами, как требует MAX API.
|
|
128
|
+
- `edit_message(..., attachments=[])` явно очищает вложения; `attachments=None` оставляет их как есть.
|
|
129
|
+
- Формат (`parse_mode`) применяется к редактированию только если передан `text`.
|
|
130
|
+
- Для upload используется multipart-поле `data`.
|
|
131
|
+
|
|
132
|
+
## Разработка
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pip install -e ".[test]"
|
|
136
|
+
pytest
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Релиз: поднять `__version__` в `src/maxapi_lite/__init__.py`, дополнить `CHANGELOG.md`,
|
|
140
|
+
создать GitHub Release с тегом `vX.Y.Z` — workflow `publish.yml` соберёт пакет и опубликует его на PyPI.
|
|
141
|
+
|
|
142
|
+
## Лицензия
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
maxapi_lite/__init__.py,sha256=a_qc31x08vLA3zWuESUrdaqPV7uacVKdbzKly28ll9o,1033
|
|
2
|
+
maxapi_lite/bot.py,sha256=wyHmt6QcvvjIYxrCa4kwNXhxLIY8AgXPEAV0g2Ho3VI,16773
|
|
3
|
+
maxapi_lite/client.py,sha256=SUROnI6SY292A9k03OZ3ZLyGqNObarXx6eeM_Z1phwU,3060
|
|
4
|
+
maxapi_lite/enums.py,sha256=bdsyuE5hQN_jd24MMob2R1sI9lChKveEJuUj6_xcLiY,497
|
|
5
|
+
maxapi_lite/exceptions.py,sha256=EjcUCpEOxYbQ2GTddelrR9ZMCkB2lxt4cK3aL_bNAcM,505
|
|
6
|
+
maxapi_lite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
maxapi_lite/types.py,sha256=dPTi4INNRu6oS0PJ9U-K3tqGLeTeblrXG5P_F_mXe3k,1270
|
|
8
|
+
maxapi_lite-0.2.0.dist-info/METADATA,sha256=od-1KG-WO5FrKPEPR2xabsiN7TKdkvDdLTezmDsJMNw,5546
|
|
9
|
+
maxapi_lite-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
maxapi_lite-0.2.0.dist-info/licenses/LICENSE,sha256=FrhT0HHTK2qCPqAUkuZpq3h62GxeU5ZxJMn3UcRl2-g,1063
|
|
11
|
+
maxapi_lite-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kiriyk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|