maxapi-sdk 0.12.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.
Files changed (48) hide show
  1. maxapi/__init__.py +49 -0
  2. maxapi/bot.py +674 -0
  3. maxapi/builders/__init__.py +23 -0
  4. maxapi/builders/keyboards.py +133 -0
  5. maxapi/builders/media.py +81 -0
  6. maxapi/callback_schema.py +140 -0
  7. maxapi/client/__init__.py +3 -0
  8. maxapi/client/default.py +89 -0
  9. maxapi/compat/__init__.py +15 -0
  10. maxapi/connection/__init__.py +3 -0
  11. maxapi/connection/base.py +136 -0
  12. maxapi/dispatcher.py +487 -0
  13. maxapi/exceptions/__init__.py +3 -0
  14. maxapi/exceptions/max.py +45 -0
  15. maxapi/filters/__init__.py +25 -0
  16. maxapi/filters/base.py +73 -0
  17. maxapi/filters/command.py +28 -0
  18. maxapi/filters/common.py +78 -0
  19. maxapi/filters/text.py +71 -0
  20. maxapi/fsm/__init__.py +17 -0
  21. maxapi/fsm/context.py +41 -0
  22. maxapi/fsm/filters.py +30 -0
  23. maxapi/fsm/middleware.py +42 -0
  24. maxapi/fsm/state.py +33 -0
  25. maxapi/fsm/storage/__init__.py +4 -0
  26. maxapi/fsm/storage/base.py +30 -0
  27. maxapi/fsm/storage/memory.py +38 -0
  28. maxapi/middlewares/__init__.py +3 -0
  29. maxapi/middlewares/base.py +34 -0
  30. maxapi/plugins/__init__.py +3 -0
  31. maxapi/plugins/base.py +19 -0
  32. maxapi/py.typed +1 -0
  33. maxapi/runners/__init__.py +4 -0
  34. maxapi/runners/polling.py +55 -0
  35. maxapi/runners/webhook.py +72 -0
  36. maxapi/transport/__init__.py +13 -0
  37. maxapi/transport/client.py +239 -0
  38. maxapi/transport/config.py +81 -0
  39. maxapi/transport/errors.py +45 -0
  40. maxapi/types/__init__.py +73 -0
  41. maxapi/types/base.py +9 -0
  42. maxapi/types/bot_mixin.py +14 -0
  43. maxapi/types/models.py +308 -0
  44. maxapi_sdk-0.12.0.dist-info/METADATA +270 -0
  45. maxapi_sdk-0.12.0.dist-info/RECORD +48 -0
  46. maxapi_sdk-0.12.0.dist-info/WHEEL +5 -0
  47. maxapi_sdk-0.12.0.dist-info/licenses/LICENSE +21 -0
  48. maxapi_sdk-0.12.0.dist-info/top_level.txt +1 -0
maxapi/bot.py ADDED
@@ -0,0 +1,674 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Iterable
7
+
8
+ from .builders import build_uploaded_attachment, normalize_attachments
9
+ from .client.default import DefaultConnectionProperties
10
+ from .connection.base import BaseConnection
11
+ from .exceptions import InvalidToken, MaxApiError
12
+ from .types import (
13
+ AddAdminsRequest,
14
+ AddMembersRequest,
15
+ AnswerCallbackRequest,
16
+ Chat,
17
+ ChatsPage,
18
+ ChatAdmin,
19
+ EditMessageRequest,
20
+ EditMessageResponse,
21
+ MembersPage,
22
+ Message,
23
+ MessageBody,
24
+ MessageList,
25
+ PinMessageRequest,
26
+ SendMessageRequest,
27
+ SendMessageResponse,
28
+ SenderAction,
29
+ SubscriptionsPage,
30
+ SuccessResponse,
31
+ TextFormat,
32
+ UpdateType,
33
+ UpdatesPage,
34
+ UploadResponse,
35
+ UploadType,
36
+ User,
37
+ VideoInfo,
38
+ WebhookRequest,
39
+ )
40
+
41
+
42
+ class Bot(BaseConnection):
43
+ """Typed client для MAX Bot API."""
44
+
45
+ def __init__(
46
+ self,
47
+ token: str | None = None,
48
+ *,
49
+ default_connection: DefaultConnectionProperties | None = None,
50
+ api_url: str = "https://platform-api.max.ru",
51
+ marker_updates: int | None = None,
52
+ auto_check_subscriptions: bool = True,
53
+ ) -> None:
54
+ super().__init__()
55
+ self.bot = self
56
+ self.default_connection = default_connection or DefaultConnectionProperties()
57
+ self.api_url = api_url.rstrip("/")
58
+ self.marker_updates = marker_updates
59
+ self.auto_check_subscriptions = auto_check_subscriptions
60
+ self.dispatcher = None
61
+ self._me: User | None = None
62
+ self.__token = token or os.environ.get("MAX_BOT_TOKEN")
63
+ if self.__token is None:
64
+ raise InvalidToken(
65
+ 'Токен не может быть None. Укажите Bot(token="...") или MAX_BOT_TOKEN.'
66
+ )
67
+ self.headers: dict[str, str] = {"Authorization": self.__token}
68
+
69
+ @property
70
+ def me(self) -> User | None:
71
+ return self._me
72
+
73
+ async def close_session(self) -> None:
74
+ if self._transport is not None:
75
+ await self._transport.close()
76
+ elif self.session is not None and not self.session.closed:
77
+ await self.session.close()
78
+
79
+ async def get_me(self) -> User:
80
+ me = await self.request("GET", "/me", model=User)
81
+ self._me = me
82
+ return me
83
+
84
+ async def get_chats(self, *, count: int | None = None, marker: int | None = None) -> ChatsPage:
85
+ params = {"count": count, "marker": marker}
86
+ params = {key: value for key, value in params.items() if value is not None}
87
+ return await self.request("GET", "/chats", model=ChatsPage, params=params)
88
+
89
+ async def get_chat(self, chat_id: int) -> Chat:
90
+ return await self.request("GET", f"/chats/{chat_id}", model=Chat)
91
+
92
+ async def update_chat(
93
+ self,
94
+ chat_id: int,
95
+ *,
96
+ title: str | None = None,
97
+ icon: dict[str, Any] | None = None,
98
+ pin: str | None = None,
99
+ notify: bool | None = None,
100
+ ) -> Chat:
101
+ payload = {
102
+ "title": title,
103
+ "icon": icon,
104
+ "pin": pin,
105
+ "notify": notify,
106
+ }
107
+ payload = {key: value for key, value in payload.items() if value is not None}
108
+ return await self.request("PATCH", f"/chats/{chat_id}", model=Chat, json=payload)
109
+
110
+ async def delete_chat(self, chat_id: int) -> SuccessResponse:
111
+ return await self.request("DELETE", f"/chats/{chat_id}", model=SuccessResponse)
112
+
113
+ async def send_chat_action(
114
+ self,
115
+ chat_id: int,
116
+ action: SenderAction = SenderAction.TYPING_ON,
117
+ ) -> SuccessResponse:
118
+ return await self.request(
119
+ "POST",
120
+ f"/chats/{chat_id}/actions",
121
+ model=SuccessResponse,
122
+ json={"action": action.value},
123
+ )
124
+
125
+ async def get_pinned_message(self, chat_id: int) -> SendMessageResponse:
126
+ return await self.request(
127
+ "GET",
128
+ f"/chats/{chat_id}/pin",
129
+ model=SendMessageResponse,
130
+ )
131
+
132
+ async def pin_message(
133
+ self,
134
+ chat_id: int,
135
+ message_id: str,
136
+ *,
137
+ notify: bool | None = True,
138
+ ) -> SuccessResponse:
139
+ payload = PinMessageRequest(message_id=message_id, notify=notify)
140
+ return await self.request(
141
+ "PUT",
142
+ f"/chats/{chat_id}/pin",
143
+ model=SuccessResponse,
144
+ json=payload.model_dump(by_alias=True, exclude_none=True),
145
+ )
146
+
147
+ async def unpin_message(self, chat_id: int) -> SuccessResponse:
148
+ return await self.request(
149
+ "DELETE",
150
+ f"/chats/{chat_id}/pin",
151
+ model=SuccessResponse,
152
+ )
153
+
154
+ async def get_membership(self, chat_id: int):
155
+ return await self.request(
156
+ "GET",
157
+ f"/chats/{chat_id}/members/me",
158
+ model=Chat,
159
+ )
160
+
161
+ async def leave_chat(self, chat_id: int) -> SuccessResponse:
162
+ return await self.request(
163
+ "DELETE",
164
+ f"/chats/{chat_id}/members/me",
165
+ model=SuccessResponse,
166
+ )
167
+
168
+ async def get_chat_admins(self, chat_id: int) -> MembersPage:
169
+ return await self.request(
170
+ "GET",
171
+ f"/chats/{chat_id}/members/admins",
172
+ model=MembersPage,
173
+ )
174
+
175
+ async def add_chat_admins(self, chat_id: int, admins: list[ChatAdmin]) -> SuccessResponse:
176
+ payload = AddAdminsRequest(admins=admins)
177
+ return await self.request(
178
+ "POST",
179
+ f"/chats/{chat_id}/members/admins",
180
+ model=SuccessResponse,
181
+ json=payload.model_dump(by_alias=True, exclude_none=True),
182
+ )
183
+
184
+ async def remove_chat_admin(self, chat_id: int, user_id: int) -> SuccessResponse:
185
+ return await self.request(
186
+ "DELETE",
187
+ f"/chats/{chat_id}/members/admins/{user_id}",
188
+ model=SuccessResponse,
189
+ )
190
+
191
+ async def get_chat_members(
192
+ self,
193
+ chat_id: int,
194
+ *,
195
+ user_ids: list[int] | None = None,
196
+ marker: int | None = None,
197
+ count: int | None = None,
198
+ ) -> MembersPage:
199
+ params = {"marker": marker, "count": count}
200
+ if user_ids:
201
+ params["user_ids"] = ",".join(str(value) for value in user_ids)
202
+ params = {key: value for key, value in params.items() if value is not None}
203
+ return await self.request(
204
+ "GET",
205
+ f"/chats/{chat_id}/members",
206
+ model=MembersPage,
207
+ params=params,
208
+ )
209
+
210
+ async def add_chat_members(self, chat_id: int, user_ids: list[int]) -> SuccessResponse:
211
+ payload = AddMembersRequest(user_ids=user_ids)
212
+ return await self.request(
213
+ "POST",
214
+ f"/chats/{chat_id}/members",
215
+ model=SuccessResponse,
216
+ json=payload.model_dump(by_alias=True, exclude_none=True),
217
+ )
218
+
219
+ async def remove_chat_member(
220
+ self,
221
+ chat_id: int,
222
+ user_id: int,
223
+ *,
224
+ block: bool | None = None,
225
+ ) -> SuccessResponse:
226
+ params = {"user_id": user_id, "block": block}
227
+ params = {key: value for key, value in params.items() if value is not None}
228
+ return await self.request(
229
+ "DELETE",
230
+ f"/chats/{chat_id}/members",
231
+ model=SuccessResponse,
232
+ params=params,
233
+ )
234
+
235
+ async def get_subscriptions(self) -> SubscriptionsPage:
236
+ return await self.request("GET", "/subscriptions", model=SubscriptionsPage)
237
+
238
+ async def set_webhook(
239
+ self,
240
+ url: str,
241
+ *,
242
+ update_types: Iterable[UpdateType | str] | None = None,
243
+ secret: str | None = None,
244
+ ) -> SuccessResponse:
245
+ normalized_types = None
246
+ if update_types is not None:
247
+ normalized_types = [item.value if hasattr(item, "value") else item for item in update_types]
248
+ payload = WebhookRequest(url=url, update_types=normalized_types, secret=secret)
249
+ return await self.request(
250
+ "POST",
251
+ "/subscriptions",
252
+ model=SuccessResponse,
253
+ json=payload.model_dump(by_alias=True, exclude_none=True),
254
+ )
255
+
256
+ async def delete_webhook(self, url: str | None = None) -> SuccessResponse:
257
+ params = {"url": url} if url is not None else None
258
+ return await self.request(
259
+ "DELETE",
260
+ "/subscriptions",
261
+ model=SuccessResponse,
262
+ params=params,
263
+ )
264
+
265
+ async def get_updates(
266
+ self,
267
+ *,
268
+ marker: int | None = None,
269
+ limit: int = 100,
270
+ timeout: int = 30,
271
+ types: Iterable[UpdateType | str] | None = None,
272
+ ) -> UpdatesPage:
273
+ params: dict[str, Any] = {"limit": limit, "timeout": timeout}
274
+ if marker is not None:
275
+ params["marker"] = marker
276
+ if types:
277
+ params["types"] = ",".join(item.value if hasattr(item, "value") else item for item in types)
278
+ return await self.request("GET", "/updates", model=UpdatesPage, params=params)
279
+
280
+ async def create_upload(self, upload_type: UploadType | str) -> UploadResponse:
281
+ upload_value = upload_type.value if hasattr(upload_type, "value") else upload_type
282
+ return await self.request(
283
+ "POST",
284
+ "/uploads",
285
+ model=UploadResponse,
286
+ params={"type": upload_value},
287
+ )
288
+
289
+ async def get_messages(
290
+ self,
291
+ *,
292
+ chat_id: int | None = None,
293
+ message_ids: list[str] | None = None,
294
+ from_time: int | None = None,
295
+ to_time: int | None = None,
296
+ count: int | None = None,
297
+ ) -> MessageList:
298
+ params: dict[str, Any] = {}
299
+ if chat_id is not None:
300
+ params["chat_id"] = chat_id
301
+ if message_ids is not None:
302
+ params["message_ids"] = ",".join(message_ids)
303
+ if from_time is not None:
304
+ params["from_time"] = from_time
305
+ if to_time is not None:
306
+ params["to_time"] = to_time
307
+ if count is not None:
308
+ params["count"] = count
309
+ return await self.request("GET", "/messages", model=MessageList, params=params)
310
+
311
+ async def get_message(self, message_id: str) -> Message:
312
+ return await self.request("GET", f"/messages/{message_id}", model=Message)
313
+
314
+ async def send_message(
315
+ self,
316
+ *,
317
+ chat_id: int | None = None,
318
+ user_id: int | None = None,
319
+ text: str | None = None,
320
+ attachments: list[dict[str, Any]] | None = None,
321
+ keyboard: Any | None = None,
322
+ link: dict[str, Any] | None = None,
323
+ format: TextFormat | None = None,
324
+ notify: bool | None = None,
325
+ disable_link_preview: bool | None = None,
326
+ ) -> SendMessageResponse:
327
+ params = {
328
+ "chat_id": chat_id,
329
+ "user_id": user_id,
330
+ "disable_link_preview": disable_link_preview,
331
+ }
332
+ params = {key: value for key, value in params.items() if value is not None}
333
+ payload = SendMessageRequest(
334
+ text=text,
335
+ attachments=normalize_attachments(attachments, keyboard=keyboard),
336
+ link=link,
337
+ notify=notify,
338
+ format=format,
339
+ )
340
+ return await self.request(
341
+ "POST",
342
+ "/messages",
343
+ model=SendMessageResponse,
344
+ params=params,
345
+ json=payload.model_dump(by_alias=True, exclude_none=True),
346
+ )
347
+
348
+ async def edit_message(
349
+ self,
350
+ *,
351
+ message_id: str,
352
+ text: str | None = None,
353
+ attachments: list[dict[str, Any]] | None = None,
354
+ keyboard: Any | None = None,
355
+ link: dict[str, Any] | None = None,
356
+ format: TextFormat | None = None,
357
+ notify: bool | None = None,
358
+ ) -> EditMessageResponse:
359
+ payload = EditMessageRequest(
360
+ message_id=message_id,
361
+ text=text,
362
+ attachments=normalize_attachments(attachments, keyboard=keyboard),
363
+ link=link,
364
+ notify=notify,
365
+ format=format,
366
+ )
367
+ return await self.request(
368
+ "PUT",
369
+ "/messages",
370
+ model=EditMessageResponse,
371
+ json=payload.model_dump(by_alias=True, exclude_none=True),
372
+ )
373
+
374
+ async def delete_message(self, message_id: str) -> SuccessResponse:
375
+ return await self.request(
376
+ "DELETE",
377
+ "/messages",
378
+ model=SuccessResponse,
379
+ params={"message_id": message_id},
380
+ )
381
+
382
+ async def get_video_info(self, message_id: str) -> VideoInfo:
383
+ return await self.request(
384
+ "GET",
385
+ f"/messages/{message_id}/video",
386
+ model=VideoInfo,
387
+ )
388
+
389
+ async def answer_callback(
390
+ self,
391
+ callback_id: str,
392
+ *,
393
+ notification: str | None = None,
394
+ message: MessageBody | None = None,
395
+ keyboard: Any | None = None,
396
+ ) -> SuccessResponse:
397
+ payload_message = message
398
+ if payload_message is not None:
399
+ payload_message = payload_message.model_copy(
400
+ update={
401
+ "attachments": normalize_attachments(
402
+ payload_message.attachments,
403
+ keyboard=keyboard,
404
+ )
405
+ }
406
+ )
407
+ elif keyboard is not None:
408
+ payload_message = MessageBody(attachments=normalize_attachments(keyboard=keyboard))
409
+ payload = AnswerCallbackRequest(notification=notification, message=payload_message)
410
+ return await self.request(
411
+ "POST",
412
+ "/answers",
413
+ model=SuccessResponse,
414
+ params={"callback_id": callback_id},
415
+ json=payload.model_dump(by_alias=True, exclude_none=True),
416
+ )
417
+
418
+ async def upload_attachment(
419
+ self,
420
+ *,
421
+ upload_type: UploadType | str,
422
+ path: str | os.PathLike[str] | None = None,
423
+ filename: str | None = None,
424
+ buffer: bytes | None = None,
425
+ ) -> dict[str, Any]:
426
+ upload_response = await self.create_upload(upload_type)
427
+ upload_value = upload_type.value if hasattr(upload_type, "value") else str(upload_type)
428
+ if path is None and buffer is None:
429
+ raise ValueError("Необходимо передать path или buffer для upload_attachment.")
430
+ if path is not None:
431
+ uploaded_payload = await self.upload_file(upload_response.url, str(path), upload_value)
432
+ else:
433
+ if filename is None:
434
+ raise ValueError("Для buffer-загрузки необходимо передать filename.")
435
+ uploaded_payload = await self.upload_file_buffer(
436
+ filename=filename,
437
+ url=upload_response.url,
438
+ buffer=buffer if buffer is not None else b"",
439
+ upload_type=upload_value,
440
+ )
441
+ if not isinstance(uploaded_payload, dict):
442
+ raise TypeError("MAX upload вернул неожиданный payload; ожидался JSON-объект.")
443
+ return build_uploaded_attachment(
444
+ upload_type=upload_value,
445
+ upload_response_token=upload_response.token,
446
+ uploaded_payload=uploaded_payload,
447
+ )
448
+
449
+ async def upload_image(self, path: str | os.PathLike[str]) -> dict[str, Any]:
450
+ return await self.upload_attachment(upload_type=UploadType.IMAGE, path=path)
451
+
452
+ async def upload_video(self, path: str | os.PathLike[str]) -> dict[str, Any]:
453
+ return await self.upload_attachment(upload_type=UploadType.VIDEO, path=path)
454
+
455
+ async def upload_audio(self, path: str | os.PathLike[str]) -> dict[str, Any]:
456
+ return await self.upload_attachment(upload_type=UploadType.AUDIO, path=path)
457
+
458
+ async def upload_file_attachment(self, path: str | os.PathLike[str]) -> dict[str, Any]:
459
+ return await self.upload_attachment(upload_type=UploadType.FILE, path=path)
460
+
461
+ async def send_media(
462
+ self,
463
+ *,
464
+ upload_type: UploadType | str,
465
+ path: str | os.PathLike[str],
466
+ chat_id: int | None = None,
467
+ user_id: int | None = None,
468
+ text: str | None = None,
469
+ keyboard: Any | None = None,
470
+ format: TextFormat | None = None,
471
+ notify: bool | None = None,
472
+ disable_link_preview: bool | None = None,
473
+ processing_wait: float = 0.0,
474
+ attachment_ready_retries: int = 3,
475
+ attachment_ready_delay: float = 1.0,
476
+ attachment_ready_backoff: float = 2.0,
477
+ ) -> SendMessageResponse:
478
+ attachment = await self.upload_attachment(upload_type=upload_type, path=path)
479
+ if processing_wait > 0:
480
+ await asyncio.sleep(processing_wait)
481
+ return await self._send_with_attachment_retry(
482
+ chat_id=chat_id,
483
+ user_id=user_id,
484
+ text=text,
485
+ attachments=[attachment],
486
+ keyboard=keyboard,
487
+ format=format,
488
+ notify=notify,
489
+ disable_link_preview=disable_link_preview,
490
+ retries=attachment_ready_retries,
491
+ retry_delay=attachment_ready_delay,
492
+ retry_backoff=attachment_ready_backoff,
493
+ )
494
+
495
+ async def send_image(
496
+ self,
497
+ path: str | os.PathLike[str],
498
+ *,
499
+ chat_id: int | None = None,
500
+ user_id: int | None = None,
501
+ text: str | None = None,
502
+ keyboard: Any | None = None,
503
+ format: TextFormat | None = None,
504
+ notify: bool | None = None,
505
+ disable_link_preview: bool | None = None,
506
+ processing_wait: float = 0.0,
507
+ attachment_ready_retries: int = 3,
508
+ attachment_ready_delay: float = 1.0,
509
+ attachment_ready_backoff: float = 2.0,
510
+ ) -> SendMessageResponse:
511
+ return await self.send_media(
512
+ upload_type=UploadType.IMAGE,
513
+ path=path,
514
+ chat_id=chat_id,
515
+ user_id=user_id,
516
+ text=text,
517
+ keyboard=keyboard,
518
+ format=format,
519
+ notify=notify,
520
+ disable_link_preview=disable_link_preview,
521
+ processing_wait=processing_wait,
522
+ attachment_ready_retries=attachment_ready_retries,
523
+ attachment_ready_delay=attachment_ready_delay,
524
+ attachment_ready_backoff=attachment_ready_backoff,
525
+ )
526
+
527
+ async def send_video(
528
+ self,
529
+ path: str | os.PathLike[str],
530
+ *,
531
+ chat_id: int | None = None,
532
+ user_id: int | None = None,
533
+ text: str | None = None,
534
+ keyboard: Any | None = None,
535
+ format: TextFormat | None = None,
536
+ notify: bool | None = None,
537
+ disable_link_preview: bool | None = None,
538
+ processing_wait: float = 0.0,
539
+ attachment_ready_retries: int = 3,
540
+ attachment_ready_delay: float = 1.0,
541
+ attachment_ready_backoff: float = 2.0,
542
+ ) -> SendMessageResponse:
543
+ return await self.send_media(
544
+ upload_type=UploadType.VIDEO,
545
+ path=path,
546
+ chat_id=chat_id,
547
+ user_id=user_id,
548
+ text=text,
549
+ keyboard=keyboard,
550
+ format=format,
551
+ notify=notify,
552
+ disable_link_preview=disable_link_preview,
553
+ processing_wait=processing_wait,
554
+ attachment_ready_retries=attachment_ready_retries,
555
+ attachment_ready_delay=attachment_ready_delay,
556
+ attachment_ready_backoff=attachment_ready_backoff,
557
+ )
558
+
559
+ async def send_audio(
560
+ self,
561
+ path: str | os.PathLike[str],
562
+ *,
563
+ chat_id: int | None = None,
564
+ user_id: int | None = None,
565
+ text: str | None = None,
566
+ keyboard: Any | None = None,
567
+ format: TextFormat | None = None,
568
+ notify: bool | None = None,
569
+ disable_link_preview: bool | None = None,
570
+ processing_wait: float = 0.0,
571
+ attachment_ready_retries: int = 3,
572
+ attachment_ready_delay: float = 1.0,
573
+ attachment_ready_backoff: float = 2.0,
574
+ ) -> SendMessageResponse:
575
+ return await self.send_media(
576
+ upload_type=UploadType.AUDIO,
577
+ path=path,
578
+ chat_id=chat_id,
579
+ user_id=user_id,
580
+ text=text,
581
+ keyboard=keyboard,
582
+ format=format,
583
+ notify=notify,
584
+ disable_link_preview=disable_link_preview,
585
+ processing_wait=processing_wait,
586
+ attachment_ready_retries=attachment_ready_retries,
587
+ attachment_ready_delay=attachment_ready_delay,
588
+ attachment_ready_backoff=attachment_ready_backoff,
589
+ )
590
+
591
+ async def send_file(
592
+ self,
593
+ path: str | os.PathLike[str],
594
+ *,
595
+ chat_id: int | None = None,
596
+ user_id: int | None = None,
597
+ text: str | None = None,
598
+ keyboard: Any | None = None,
599
+ format: TextFormat | None = None,
600
+ notify: bool | None = None,
601
+ disable_link_preview: bool | None = None,
602
+ processing_wait: float = 0.0,
603
+ attachment_ready_retries: int = 3,
604
+ attachment_ready_delay: float = 1.0,
605
+ attachment_ready_backoff: float = 2.0,
606
+ ) -> SendMessageResponse:
607
+ return await self.send_media(
608
+ upload_type=UploadType.FILE,
609
+ path=path,
610
+ chat_id=chat_id,
611
+ user_id=user_id,
612
+ text=text,
613
+ keyboard=keyboard,
614
+ format=format,
615
+ notify=notify,
616
+ disable_link_preview=disable_link_preview,
617
+ processing_wait=processing_wait,
618
+ attachment_ready_retries=attachment_ready_retries,
619
+ attachment_ready_delay=attachment_ready_delay,
620
+ attachment_ready_backoff=attachment_ready_backoff,
621
+ )
622
+
623
+ async def send_text(self, *args: Any, **kwargs: Any) -> SendMessageResponse:
624
+ return await self.send_message(*args, **kwargs)
625
+
626
+ async def edit_text(self, *args: Any, **kwargs: Any) -> EditMessageResponse:
627
+ return await self.edit_message(*args, **kwargs)
628
+
629
+ async def delete(self, message_id: str) -> SuccessResponse:
630
+ return await self.delete_message(message_id)
631
+
632
+ async def answer_callback_query(self, callback_id: str, **kwargs: Any) -> SuccessResponse:
633
+ return await self.answer_callback(callback_id, **kwargs)
634
+
635
+ async def _send_with_attachment_retry(
636
+ self,
637
+ *,
638
+ chat_id: int | None,
639
+ user_id: int | None,
640
+ text: str | None,
641
+ attachments: list[dict[str, Any]] | None,
642
+ keyboard: Any | None,
643
+ format: TextFormat | None,
644
+ notify: bool | None,
645
+ disable_link_preview: bool | None,
646
+ retries: int,
647
+ retry_delay: float,
648
+ retry_backoff: float,
649
+ ) -> SendMessageResponse:
650
+ attempt = 0
651
+ current_delay = retry_delay
652
+ while True:
653
+ try:
654
+ return await self.send_message(
655
+ chat_id=chat_id,
656
+ user_id=user_id,
657
+ text=text,
658
+ attachments=attachments,
659
+ keyboard=keyboard,
660
+ format=format,
661
+ notify=notify,
662
+ disable_link_preview=disable_link_preview,
663
+ )
664
+ except MaxApiError as exc:
665
+ if not self._is_attachment_not_ready(exc) or attempt >= retries:
666
+ raise
667
+ attempt += 1
668
+ await asyncio.sleep(current_delay)
669
+ current_delay *= max(retry_backoff, 1.0)
670
+
671
+ @staticmethod
672
+ def _is_attachment_not_ready(exc: MaxApiError) -> bool:
673
+ raw = exc.raw if isinstance(exc.raw, dict) else {}
674
+ return raw.get("code") == "attachment.not.ready"
@@ -0,0 +1,23 @@
1
+ from .keyboards import InlineKeyboardBuilder
2
+ from .media import (
3
+ audio_attachment,
4
+ build_uploaded_attachment,
5
+ file_attachment,
6
+ image_attachment,
7
+ make_attachment,
8
+ normalize_attachment,
9
+ normalize_attachments,
10
+ video_attachment,
11
+ )
12
+
13
+ __all__ = [
14
+ "InlineKeyboardBuilder",
15
+ "audio_attachment",
16
+ "build_uploaded_attachment",
17
+ "file_attachment",
18
+ "image_attachment",
19
+ "make_attachment",
20
+ "normalize_attachment",
21
+ "normalize_attachments",
22
+ "video_attachment",
23
+ ]