maxkit 2.13.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.
aiomax/bot.py ADDED
@@ -0,0 +1,1166 @@
1
+ import asyncio
2
+ import logging
3
+ import os
4
+ import ssl
5
+ from collections.abc import AsyncIterator
6
+ from typing import IO, BinaryIO, Literal
7
+
8
+ import aiofiles
9
+ import aiohttp
10
+ from aiohttp.client_exceptions import ClientConnectorCertificateError
11
+
12
+ from . import buttons, exceptions, fsm, utils
13
+ from .cache import MessageCache
14
+ from .router import Router
15
+ from .types import (
16
+ Attachment,
17
+ AudioAttachment,
18
+ BotCommand,
19
+ BotStartPayload,
20
+ Callback,
21
+ Chat,
22
+ ChatCreatePayload,
23
+ ChatMembershipPayload,
24
+ ChatTitleEditPayload,
25
+ CommandContext,
26
+ FileAttachment,
27
+ ImageRequestPayload,
28
+ Message,
29
+ MessageDeletePayload,
30
+ PhotoAttachment,
31
+ User,
32
+ UserMembershipPayload,
33
+ VideoAttachment,
34
+ )
35
+
36
+ bot_logger = logging.getLogger("aiomax.bot")
37
+
38
+
39
+ class Bot(Router):
40
+ def __init__(
41
+ self,
42
+ access_token: str,
43
+ command_prefixes: "str | list[str]" = "/",
44
+ mention_prefix: bool = True,
45
+ case_sensitive: bool = True,
46
+ default_format: "Literal['markdown', 'html'] | None" = None,
47
+ max_messages_cached: int = 10000,
48
+ use_certificate: bool = False,
49
+ api_url: str = "https://platform-api2.max.ru/",
50
+ shutdown_timeout: "float | None" = 5.0,
51
+ attachment_retries: int = 10,
52
+ ):
53
+ """
54
+ Bot init
55
+
56
+ :param access_token: Bot access token from https://max.ru/masterbot
57
+ :param command_prefixes: List of command prefixes or a command prefix
58
+ :param mention_prefix: Whether to respond to commands starting with
59
+ the ping of the bot
60
+ :param case_sensitive: If False the bot will respond to commands
61
+ regardless of case
62
+ :param default_format: Default message formatting mode
63
+ :param max_messages_cached: Maximum number of messages to cache.
64
+ Set to 0 to disable caching
65
+ :param use_certificate: Whether to automatically use
66
+ a Russian Mintsifra SSL certificate
67
+ :param shutdown_timeout: How long (in seconds) to wait for running
68
+ handlers to finish on shutdown before cancelling them. ``None`` waits
69
+ indefinitely.
70
+ :param attachment_retries: How many times to retry sending/editing a
71
+ message while its attachment is still being processed by the server
72
+ before giving up and raising ``AttachmentNotReady``.
73
+ """
74
+ super().__init__(case_sensitive)
75
+
76
+ self.use_certificate: bool = use_certificate
77
+ self.api_url: str = api_url
78
+ self.shutdown_timeout: "float | None" = shutdown_timeout
79
+ self.attachment_retries: int = attachment_retries
80
+
81
+ self.access_token: str = access_token
82
+ self.session: aiohttp.ClientSession | None = None
83
+ self.polling = False
84
+ self._handler_tasks: set[asyncio.Task] = set()
85
+ # Per-user locks serialise handler execution for a single user so
86
+ # concurrent updates from the same user cannot race on FSM state.
87
+ self._user_locks: dict[int, asyncio.Lock] = {}
88
+ self._user_lock_counts: dict[int, int] = {}
89
+
90
+ self.command_prefixes: str | list[str] = command_prefixes
91
+ self.mention_prefix: bool = mention_prefix
92
+ self.default_format: str | None = default_format
93
+ self.cache: MessageCache | None = (
94
+ MessageCache(max_messages_cached)
95
+ if max_messages_cached > 0
96
+ else None
97
+ )
98
+
99
+ self.id: int | None = None
100
+ self.username: str | None = None
101
+ self.name: str | None = None
102
+ self.description: str | None = None
103
+ self.bot_commands: list[BotCommand] | None = None
104
+
105
+ self.marker: int | None = None
106
+
107
+ self.storage = fsm.FSMStorage()
108
+
109
+ async def get(self, url: str, *args, **kwargs):
110
+ """
111
+ Sends a GET request to the API.
112
+ """
113
+ if self.session is None:
114
+ raise Exception("Session is not initialized")
115
+
116
+ params = kwargs.get("params", {})
117
+ if "params" in kwargs:
118
+ del kwargs["params"]
119
+
120
+ response = await self.session.get(url, *args, params=params, **kwargs)
121
+
122
+ exception = await utils.get_exception(response)
123
+
124
+ if not exception:
125
+ return response
126
+ raise exception
127
+
128
+ async def post(self, url: str, *args, **kwargs):
129
+ """
130
+ Sends a POST request to the API.
131
+ """
132
+ if self.session is None:
133
+ raise Exception("Session is not initialized")
134
+
135
+ params = kwargs.get("params", {})
136
+ if "params" in kwargs:
137
+ del kwargs["params"]
138
+
139
+ response = await self.session.post(url, *args, params=params, **kwargs)
140
+
141
+ exception = await utils.get_exception(response)
142
+
143
+ if not exception:
144
+ return response
145
+ raise exception
146
+
147
+ async def patch(self, url: str, *args, **kwargs):
148
+ """
149
+ Sends a PATCH request to the API.
150
+ """
151
+ if self.session is None:
152
+ raise Exception("Session is not initialized")
153
+
154
+ params = kwargs.get("params", {})
155
+ if "params" in kwargs:
156
+ del kwargs["params"]
157
+
158
+ response = await self.session.patch(
159
+ url, *args, params=params, **kwargs
160
+ )
161
+
162
+ exception = await utils.get_exception(response)
163
+
164
+ if not exception:
165
+ return response
166
+ raise exception
167
+
168
+ async def put(self, url: str, *args, **kwargs):
169
+ """
170
+ Sends a PUT request to the API.
171
+ """
172
+ if self.session is None:
173
+ raise Exception("Session is not initialized")
174
+
175
+ params = kwargs.get("params", {})
176
+ if "params" in kwargs:
177
+ del kwargs["params"]
178
+
179
+ response = await self.session.put(url, *args, params=params, **kwargs)
180
+
181
+ exception = await utils.get_exception(response)
182
+
183
+ if not exception:
184
+ return response
185
+ raise exception
186
+
187
+ async def delete(self, url: str, *args, **kwargs):
188
+ """
189
+ Sends a DELETE request to the API.
190
+ """
191
+ if self.session is None:
192
+ raise Exception("Session is not initialized")
193
+
194
+ params = kwargs.get("params", {})
195
+ if "params" in kwargs:
196
+ del kwargs["params"]
197
+
198
+ response = await self.session.delete(
199
+ url, *args, params=params, **kwargs
200
+ )
201
+
202
+ exception = await utils.get_exception(response)
203
+
204
+ if not exception:
205
+ return response
206
+ raise exception
207
+
208
+ # send requests
209
+
210
+ async def get_me(self) -> User:
211
+ """
212
+ Returns info about the bot.
213
+ """
214
+ response = await self.get("me")
215
+ user = await response.json()
216
+ user = User.from_json(user)
217
+
218
+ # caching info
219
+ self.id = user.user_id
220
+ self.username = user.username
221
+ self.name = user.name
222
+ self.bot_commands = user.commands
223
+ self.description = user.description
224
+ return user
225
+
226
+ async def patch_me(
227
+ self,
228
+ name: "str | None" = None,
229
+ description: "str | None" = None,
230
+ commands: "list[BotCommand] | None" = None,
231
+ photo: "ImageRequestPayload | None" = None,
232
+ ) -> User:
233
+ """
234
+ Allows you to change info about the bot. Fill in only the fields that
235
+ need to be updated.
236
+
237
+ :param name: Bot display name
238
+ :param description: Bot description
239
+ :param commands: Commands supported by the bot. To remove all commands,
240
+ pass an empty list.
241
+ :param photo: Bot profile pictur
242
+ """
243
+ if commands:
244
+ commands = [i.as_dict() for i in commands]
245
+ if photo:
246
+ photo = photo.as_dict()
247
+
248
+ payload = {
249
+ "name": name,
250
+ "description": description,
251
+ "commands": commands,
252
+ "photo": photo,
253
+ }
254
+ payload = {k: v for k, v in payload.items() if v}
255
+
256
+ response = await self.patch("me", json=payload)
257
+ data = await response.json()
258
+
259
+ # caching info
260
+ if name:
261
+ self.name = name
262
+ if commands:
263
+ self.bot_commands = commands
264
+ if description:
265
+ self.description = description
266
+
267
+ return User.from_json(data)
268
+
269
+ async def get_chats(
270
+ self, count_per_iter: int = 100
271
+ ) -> AsyncIterator[Chat]:
272
+ """
273
+ Returns an asynchronous interator of chats the bot is in.
274
+
275
+ :param count_per_iter: The number of chats to fetch per request.
276
+ """
277
+ marker = None
278
+
279
+ while True:
280
+ params = {
281
+ "count": count_per_iter,
282
+ "marker": marker,
283
+ }
284
+ params = {k: v for k, v in params.items() if v}
285
+ response = await self.get("chats", params=params)
286
+ data = await response.json()
287
+
288
+ for chat in data["chats"]:
289
+ yield Chat.from_json(chat)
290
+
291
+ marker = data.get("marker", None)
292
+ if marker is None:
293
+ break
294
+
295
+ async def chat_by_link(self, link: str) -> Chat:
296
+ """
297
+ Returns chat by a link or username.
298
+
299
+ :param link: Public chat link or username.
300
+ """
301
+ response = await self.get(f"chats/{link}")
302
+ json = await response.json()
303
+
304
+ return Chat.from_json(json)
305
+
306
+ async def get_chat(self, chat_id: int) -> Chat:
307
+ """
308
+ Returns information about a chat.
309
+
310
+ :param chat_id: The ID of the chat.
311
+ """
312
+ response = await self.get(f"chats/{chat_id}")
313
+ json = await response.json()
314
+
315
+ return Chat.from_json(json)
316
+
317
+ async def get_pin(self, chat_id: int) -> "Message | None":
318
+ """
319
+ Returns pinned message in the chat as ``. None if there is no pinned
320
+ message
321
+
322
+ :param chat_id: The ID of the chat.
323
+ """
324
+ response = await self.get(f"chats/{chat_id}/pin")
325
+ json = await response.json()
326
+
327
+ if json["message"] is None:
328
+ return None
329
+
330
+ return Message.from_json(json["message"])
331
+
332
+ async def pin(
333
+ self, chat_id: int, message_id: str, notify: "bool | None" = None
334
+ ):
335
+ """
336
+ Pin a message in a chat
337
+
338
+ :param chat_id: The ID of the chat.
339
+ :param message_id: The ID of the message to pin.
340
+ :param notify: Whether to notify users about the pin. True by default.
341
+ """
342
+ payload = {"message_id": message_id, "notify": notify}
343
+ payload = {k: v for k, v in payload.items() if v is not None}
344
+
345
+ response = await self.put(f"chats/{chat_id}/pin", json=payload)
346
+ return await response.json()
347
+
348
+ async def delete_pin(self, chat_id: int):
349
+ """
350
+ Delete pinned message in the chat
351
+
352
+ :param chat_id: The ID of the chat.
353
+ """
354
+ response = await self.delete(f"chats/{chat_id}/pin")
355
+
356
+ return await response.json()
357
+
358
+ async def my_membership(self, chat_id: int) -> User:
359
+ """
360
+ Returns information about the bot's membership in the chat.
361
+
362
+ :param chat_id: The ID of the chat.
363
+ """
364
+ response = await self.get(f"chats/{chat_id}/members/me")
365
+ json = await response.json()
366
+
367
+ return User.from_json(json)
368
+
369
+ async def leave_chat(self, chat_id: int):
370
+ """
371
+ Remove the bot from the chat.
372
+
373
+ :param chat_id: The ID of the chat.
374
+ """
375
+ response = await self.delete(f"chats/{chat_id}/members/me")
376
+
377
+ return await response.json()
378
+
379
+ async def get_admins(self, chat_id: int) -> list[User]:
380
+ """
381
+ Returns a list of administrators in the chat.
382
+
383
+ :param chat_id: The ID of the chat.
384
+ """
385
+ response = await self.get(f"chats/{chat_id}/members/admins")
386
+
387
+ users = [User.from_json(i) for i in (await response.json())["members"]]
388
+
389
+ return users
390
+
391
+ async def get_memberships(
392
+ self, chat_id: int, user_ids: "list[int] | int"
393
+ ) -> "list[User] | User | None":
394
+ """
395
+ Returns a list of memberships in the chat for the users with the
396
+ specified ID.
397
+ """
398
+ params = {
399
+ "user_ids": user_ids if isinstance(user_ids, list) else [user_ids]
400
+ }
401
+ response = await self.get(
402
+ f"chats/{chat_id}/members",
403
+ params=params,
404
+ )
405
+
406
+ users = [User.from_json(i) for i in (await response.json())["members"]]
407
+
408
+ if isinstance(user_ids, list):
409
+ return users
410
+ else:
411
+ return users[0] if len(users) > 0 else None
412
+
413
+ async def get_members(
414
+ self, chat_id: int, count_per_iter: int = 100
415
+ ) -> AsyncIterator[User]:
416
+ """
417
+ Returns an asynchronous interator of members in the chat.
418
+
419
+ :param chat_id: The ID of the chat.
420
+ :param count_per_iter: The number of users to fetch per request.
421
+ """
422
+ marker = None
423
+
424
+ while True:
425
+ params = {
426
+ "count": count_per_iter,
427
+ "marker": marker,
428
+ }
429
+ params = {k: v for k, v in params.items() if v}
430
+ response = await self.get(
431
+ f"chats/{chat_id}/members",
432
+ params=params,
433
+ )
434
+ data = await response.json()
435
+
436
+ for user in data["members"]:
437
+ yield User.from_json(user)
438
+
439
+ marker = data.get("marker", None)
440
+ if marker is None:
441
+ break
442
+
443
+ async def add_members(self, chat_id: int, users: list[int]):
444
+ """
445
+ Adds users to the chat.
446
+
447
+ :param chat_id: The ID of the chat.
448
+ :param users: List of user IDs to add.
449
+ """
450
+
451
+ response = await self.post(
452
+ f"chats/{chat_id}/members",
453
+ json={"user_ids": users},
454
+ )
455
+
456
+ return await response.json()
457
+
458
+ async def kick_member(
459
+ self, chat_id: int, user_id: int, block: "bool | None" = None
460
+ ):
461
+ """
462
+ Removes a user from the chat.
463
+
464
+ :param chat_id: The ID of the chat.
465
+ :param user_id: The ID of the user to remove.
466
+ :param block: Whether to block the user. Ignored by default.
467
+ """
468
+
469
+ params = {"chat_id": chat_id, "user_id": user_id, "block": block}
470
+ params = {k: v for k, v in params.items() if v}
471
+
472
+ if block is not None:
473
+ params["block"] = str(block)
474
+
475
+ response = await self.delete(
476
+ f"chats/{chat_id}/members/",
477
+ params=params,
478
+ )
479
+
480
+ return await response.json()
481
+
482
+ async def patch_chat(
483
+ self,
484
+ chat_id: int,
485
+ icon: "ImageRequestPayload | None" = None,
486
+ title: "str | None" = None,
487
+ pin: "str | None" = None,
488
+ notify: "bool | None" = None,
489
+ ) -> Chat:
490
+ """
491
+ Allows you to edit chat information, like the name,
492
+ icon and pinned message.
493
+
494
+ :param chat_id: ID of the chat to change
495
+ :param icon: Chat picture
496
+ :param title: Chat name. From 1 to 200 characters
497
+ :param pin: ID of the message to pin
498
+ :param notify: Whether to notify users about the edit. True by default.
499
+ """
500
+
501
+ payload = {
502
+ "icon": icon.as_dict() if icon else None,
503
+ "title": title,
504
+ "pin": pin,
505
+ "notify": notify,
506
+ }
507
+ payload = {k: v for k, v in payload.items() if v is not None}
508
+
509
+ response = await self.patch(f"chats/{chat_id}", json=payload)
510
+ json = await response.json()
511
+
512
+ return Chat.from_json(json)
513
+
514
+ async def post_action(self, chat_id: int, action: str):
515
+ """
516
+ Allows you to show a badge about performing an action in a chat, like
517
+ "typing". Also allows for marking messages as read.
518
+
519
+ :param chat_id: ID of the chat to do the action in
520
+ :param action: The action to perform
521
+ """
522
+
523
+ response = await self.post(
524
+ f"chats/{chat_id}/actions",
525
+ json={"action": action},
526
+ )
527
+
528
+ return await response.json()
529
+
530
+ async def _upload(
531
+ self, data: "IO | str", type: str, filename: "str | None" = None
532
+ ) -> dict:
533
+ """
534
+ Uploads a file to the server. Returns raw JSON with the token.
535
+
536
+ :param data: File-like object or path to the file
537
+ :param type: File type
538
+ :param filename: Optional file name sent alongside the file
539
+ """
540
+ if isinstance(data, str):
541
+ async with aiofiles.open(data, "rb") as f:
542
+ data = await f.read()
543
+
544
+ # The form field name must be a fixed, safe literal. Passing an
545
+ # attacker-influenced filename as the field name (previously the case
546
+ # for upload_file) with quote_fields disabled allowed header injection
547
+ # into the multipart request. aiohttp encodes ``filename`` safely.
548
+ form = aiohttp.FormData()
549
+ form.add_field("data", data, filename=filename)
550
+
551
+ url_resp = await self.post("uploads", params={"type": type})
552
+ url_json = await url_resp.json()
553
+ token_resp = await self.session.post(url_json["url"], data=form)
554
+ token_resp.raise_for_status()
555
+
556
+ if type in {"audio", "video"}:
557
+ return url_json
558
+
559
+ token_json = await token_resp.json()
560
+ return token_json
561
+
562
+ async def upload_image(self, data: "BinaryIO | str") -> PhotoAttachment:
563
+ """
564
+ Uploads an image to the server and returns a PhotoAttachment.
565
+
566
+ :param data: File-like object or path to the file
567
+ """
568
+ raw_photo = await self._upload(data, "image")
569
+ token = list(raw_photo["photos"].values())[0]["token"]
570
+ return PhotoAttachment(token=token)
571
+
572
+ async def upload_video(self, data: "BinaryIO | str") -> VideoAttachment:
573
+ """
574
+ Uploads a video to the server and returns a VideoAttachment.
575
+
576
+ :param data: File-like object or path to the file
577
+ """
578
+ raw_video = await self._upload(data, "video")
579
+ token = raw_video["token"]
580
+ return VideoAttachment(token=token)
581
+
582
+ async def upload_audio(self, data: "BinaryIO | str") -> AudioAttachment:
583
+ """
584
+ Uploads an audio file to the server and returns an AudioAttachment.
585
+
586
+ :param data: File-like object or path to the file
587
+ """
588
+ raw_audio = await self._upload(data, "audio")
589
+ token = raw_audio["token"]
590
+ return AudioAttachment(token=token)
591
+
592
+ async def upload_file(
593
+ self, data: "IO | str", filename: "str | None" = None
594
+ ) -> FileAttachment:
595
+ """
596
+ Uploads a file to the server and returns a FileAttachment.
597
+
598
+ :param data: File-like object or path to the file
599
+ :param filename: Filename that will be uploaded
600
+ """
601
+ if filename is None:
602
+ if isinstance(data, str):
603
+ filename = os.path.basename(data)
604
+ elif hasattr(data, "name"):
605
+ filename = data.name
606
+ else:
607
+ raise exceptions.FilenameNotProvided(
608
+ "filename is required for use with "
609
+ f"object of type {type(data).__name__}"
610
+ )
611
+
612
+ raw_file = await self._upload(data, "file", filename)
613
+ token = raw_file["token"]
614
+ return FileAttachment(token=token)
615
+
616
+ async def send_message(
617
+ self,
618
+ text: "str | None" = None,
619
+ chat_id: "int | None" = None,
620
+ user_id: "int | None" = None,
621
+ format: "Literal['markdown', 'html', 'default'] | None" = "default",
622
+ reply_to: "int | None" = None,
623
+ notify: bool = True,
624
+ disable_link_preview: bool = False,
625
+ keyboard: """list[list[buttons.Button]] \
626
+ | buttons.KeyboardBuilder \
627
+ | None""" = None,
628
+ attachments: "list[Attachment] | Attachment | None" = None,
629
+ ) -> Message:
630
+ """
631
+ Allows you to send a message to a user or in a chat.
632
+
633
+ :param text: Message text. Up to 4000 characters
634
+ :param chat_id: Chat ID to send the message in.
635
+ :param user_id: User ID to send the message to.
636
+ :param format: Message format. Bot.default_format by default
637
+ :param reply_to: ID of the message to reply to. Optional
638
+ :param notify: Whether to notify users about the message.
639
+ True by default.
640
+ :param disable_link_preview: Whether to disable link embedding
641
+ in messages. True by default
642
+ :param keyboard: An inline keyboard to attach to the message
643
+ :param attachments: List of attachments
644
+ """
645
+ # error checking
646
+ if chat_id is None and user_id is None:
647
+ raise exceptions.AiomaxException(
648
+ "Either chat_id or user_id must be provided"
649
+ )
650
+ if not (chat_id is None or user_id is None):
651
+ raise exceptions.AiomaxException(
652
+ "Both chat_id and user_id cannot be provided"
653
+ )
654
+
655
+ # sending
656
+ params = {
657
+ "chat_id": chat_id,
658
+ "user_id": user_id,
659
+ "disable_link_preview": str(disable_link_preview).lower(),
660
+ }
661
+ params = {k: v for k, v in params.items() if v}
662
+
663
+ if format == "default":
664
+ format = self.default_format
665
+
666
+ body = utils.get_message_body(
667
+ text, format, reply_to, notify, keyboard, attachments
668
+ )
669
+
670
+ # Retry a bounded number of times while the attachment is still being
671
+ # processed, instead of recursing forever (which grew the stack until
672
+ # RecursionError and blocked the handler indefinitely).
673
+ for attempt in range(self.attachment_retries + 1):
674
+ try:
675
+ response = await self.post(
676
+ "messages",
677
+ params=params,
678
+ json=body,
679
+ )
680
+ json = await response.json()
681
+ if not json.get("success", True):
682
+ # get_exception() returns None for 2xx responses, so a
683
+ # bare `raise await get_exception(...)` would `raise None`
684
+ # (TypeError). Fall back to a real exception.
685
+ exception = await utils.get_exception(response)
686
+ raise exception or exceptions.UnknownErrorException(
687
+ json.get("code"), json.get("message")
688
+ )
689
+ message = Message.from_json(json["message"])
690
+ message.bot = self
691
+ return message
692
+
693
+ except exceptions.AttachmentNotReady:
694
+ if attempt >= self.attachment_retries:
695
+ raise
696
+ await asyncio.sleep(1)
697
+
698
+ async def edit_message(
699
+ self,
700
+ message_id: str,
701
+ text: "str | None" = None,
702
+ format: "Literal['markdown', 'html', 'default'] | None" = "default",
703
+ reply_to: "int | None" = None,
704
+ notify: bool = True,
705
+ keyboard: """list[list[buttons.Button]] \
706
+ | buttons.KeyboardBuilder \
707
+ | None""" = None,
708
+ attachments: "list[Attachment] | Attachment | None" = None,
709
+ ) -> Message:
710
+ """
711
+ Allows you to edit a message.
712
+
713
+ :param message_id: ID of the message to edit
714
+ :param text: Message text. Up to 4000 characters
715
+ :param format: Message format. Bot.default_format by default
716
+ :param reply_to: ID of the message to reply to. Optional
717
+ :param notify: Whether to notify users about the message.
718
+ True by default.
719
+ :param keyboard: An inline keyboard to attach to the message
720
+ :param attachments: List of attachments
721
+ """
722
+ # editing
723
+ params = {"message_id": message_id}
724
+ if format == "default":
725
+ format = self.default_format
726
+
727
+ body = utils.get_message_body(
728
+ text, format, reply_to, notify, keyboard, attachments
729
+ )
730
+
731
+ for attempt in range(self.attachment_retries + 1):
732
+ try:
733
+ response = await self.put(
734
+ "messages",
735
+ params=params,
736
+ json=body,
737
+ )
738
+ json = await response.json()
739
+ if not json.get("success", True):
740
+ # Never fall through to Message.from_json() on failure (it
741
+ # would return a broken Message with body=None); and never
742
+ # `raise None` when get_exception() returns None for 2xx.
743
+ exception = await utils.get_exception(response)
744
+ raise exception or exceptions.UnknownErrorException(
745
+ json.get("code"), json.get("message")
746
+ )
747
+ # The edit endpoint may return either the bare message or a
748
+ # ``{"message": {...}}`` envelope; handle both instead of
749
+ # building a broken Message from the wrong shape.
750
+ message = Message.from_json(json.get("message") or json)
751
+ message.bot = self
752
+ return message
753
+
754
+ except exceptions.AttachmentNotReady:
755
+ if attempt >= self.attachment_retries:
756
+ raise
757
+ await asyncio.sleep(1)
758
+
759
+ async def delete_message(self, message_id: str):
760
+ """
761
+ Allows you to delete a message in chat.
762
+
763
+ :param message_id: ID of the message to delete
764
+ """
765
+ # editing
766
+ params = {"message_id": message_id}
767
+
768
+ response = await self.delete("messages", params=params)
769
+
770
+ json = await response.json()
771
+ if not json["success"]:
772
+ raise Exception(json["message"])
773
+
774
+ async def get_message(self, message_id: str) -> Message:
775
+ """
776
+ Allows you to fetch message's info.
777
+
778
+ :param message_id: ID of the message to get info of
779
+ """
780
+ try:
781
+ response = await self.get(f"messages/{message_id}")
782
+
783
+ data = await response.json()
784
+
785
+ return Message.from_json(data)
786
+ except exceptions.NotFoundException:
787
+ raise exceptions.MessageNotFoundException from None
788
+
789
+ async def get_updates(self, limit: int = 100) -> tuple[int, dict]:
790
+ """
791
+ Get bot updates / events.
792
+
793
+ :param limit: Maximum amount of updates to return.
794
+ """
795
+ payload = {"limit": limit, "marker": self.marker}
796
+ payload = {k: v for k, v in payload.items() if v}
797
+
798
+ response = await self.get("updates", params=payload)
799
+ json = await response.json()
800
+ if "marker" in json:
801
+ self.marker = json["marker"]
802
+
803
+ return json
804
+
805
+ def _run_handler(self, coro, user_id: "int | None" = None) -> asyncio.Task:
806
+ """
807
+ Runs a handler as a task, keeping a reference to it until it
808
+ finishes so that it is not garbage collected mid-flight and can
809
+ be awaited on shutdown.
810
+
811
+ If ``user_id`` is given, handlers for the same user are serialised:
812
+ the next update from that user waits for the previous handler to
813
+ finish, so they cannot race on shared FSM state.
814
+ """
815
+ if user_id is not None:
816
+ coro = self._run_serialized(coro, user_id)
817
+ task = asyncio.create_task(coro)
818
+ self._handler_tasks.add(task)
819
+ task.add_done_callback(self._handler_tasks.discard)
820
+ return task
821
+
822
+ async def _run_serialized(self, coro, user_id: int):
823
+ """
824
+ Runs ``coro`` while holding the per-user lock, cleaning the lock up
825
+ once no more handlers are queued for that user (bounded memory).
826
+ """
827
+ lock = self._user_locks.get(user_id)
828
+ if lock is None:
829
+ lock = asyncio.Lock()
830
+ self._user_locks[user_id] = lock
831
+
832
+ self._user_lock_counts[user_id] = (
833
+ self._user_lock_counts.get(user_id, 0) + 1
834
+ )
835
+ try:
836
+ async with lock:
837
+ await coro
838
+ finally:
839
+ self._user_lock_counts[user_id] -= 1
840
+ if self._user_lock_counts[user_id] <= 0:
841
+ self._user_lock_counts.pop(user_id, None)
842
+ self._user_locks.pop(user_id, None)
843
+
844
+ async def handle_update(self, update: dict):
845
+ """
846
+ Handles an update.
847
+ """
848
+ update_type = update["update_type"]
849
+
850
+ if update_type == "message_created":
851
+ message = Message.from_json(update["message"])
852
+ message.bot = self
853
+ message.user_locale = update.get("user_locale")
854
+ cursor = fsm.FSMCursor(self.storage, message.sender.user_id)
855
+
856
+ # caching
857
+ if self.cache:
858
+ self.cache.add_message(message)
859
+
860
+ # handling commands
861
+ prefixes = (
862
+ self.command_prefixes
863
+ if not isinstance(self.command_prefixes, str)
864
+ else [self.command_prefixes]
865
+ )
866
+ prefixes = list(prefixes)
867
+ handled = False
868
+ block = False
869
+
870
+ if self.mention_prefix:
871
+ prefixes.extend([f"@{self.username} {i}" for i in prefixes])
872
+
873
+ # Media-only messages (stickers, photos/files without a caption)
874
+ # arrive with body.text=None and cannot be commands. Fall back to
875
+ # an empty string so the checks below skip them instead of
876
+ # raising `TypeError: object of type 'NoneType' has no len()`.
877
+ text = message.body.text or ""
878
+
879
+ for prefix in prefixes:
880
+ if len(text) <= len(prefix):
881
+ continue
882
+
883
+ if self.case_sensitive:
884
+ if not text.startswith(prefix):
885
+ continue
886
+ else:
887
+ if not text.lower().startswith(prefix.lower()):
888
+ continue
889
+
890
+ command = text[len(prefix) :]
891
+ parts = command.split()
892
+ if not parts:
893
+ # Prefix followed by whitespace only (e.g. "/ ") — not a
894
+ # command; avoid IndexError on parts[0].
895
+ continue
896
+ name = parts[0]
897
+ check_name = name if self.case_sensitive else name.lower()
898
+ args = " ".join(parts[1:])
899
+
900
+ if check_name not in self.commands:
901
+ bot_logger.debug(f'Command "{name}" not handled')
902
+ continue
903
+
904
+ if len(self.commands[check_name]) == 0:
905
+ bot_logger.debug(f'Command "{name}" not handled')
906
+ continue
907
+
908
+ for i in self.commands[check_name]:
909
+ kwargs = utils.context_kwargs(i.call, cursor=cursor)
910
+ self._run_handler(
911
+ i.call(
912
+ CommandContext(self, message, name, args), **kwargs
913
+ ),
914
+ user_id=cursor.user_id,
915
+ )
916
+
917
+ if not i.as_message:
918
+ block = True
919
+
920
+ bot_logger.debug(f'Command "{name}" handled')
921
+
922
+ # handling
923
+ handled = False
924
+
925
+ for handler in self.handlers["message_created"]:
926
+ if not handler.detect_commands and block:
927
+ continue
928
+
929
+ filters = [filter(message) for filter in handler.filters]
930
+
931
+ if all(filters):
932
+ kwargs = utils.context_kwargs(handler.call, cursor=cursor)
933
+ self._run_handler(
934
+ handler.call(message, **kwargs),
935
+ user_id=cursor.user_id,
936
+ )
937
+ handled = True
938
+
939
+ # handle logs
940
+ if handled:
941
+ bot_logger.debug(f'Message "{message.body.text}" handled')
942
+ else:
943
+ bot_logger.debug(f'Message "{message.body.text}" not handled')
944
+
945
+ if update_type == "message_edited":
946
+ message = Message.from_json(update["message"])
947
+ message.bot = self
948
+ message.user_locale = update.get("user_locale")
949
+ cursor = fsm.FSMCursor(self.storage, message.sender.user_id)
950
+
951
+ # caching
952
+ old_message = None
953
+ if self.cache:
954
+ old_message = self.cache.get_message(message.id)
955
+ self.cache.add_message(message)
956
+
957
+ # handling
958
+ for handler in self.handlers[update_type]:
959
+ filters = [filter(message) for filter in handler.filters]
960
+
961
+ if all(filters):
962
+ kwargs = utils.context_kwargs(
963
+ handler.call,
964
+ cursor=cursor,
965
+ )
966
+ self._run_handler(
967
+ handler.call(old_message, message, **kwargs),
968
+ user_id=cursor.user_id,
969
+ )
970
+
971
+ # handle logs
972
+ bot_logger.debug(f'Message "{message.body.text}" edited')
973
+
974
+ if update_type == "message_removed":
975
+ payload = MessageDeletePayload.from_json(update, self)
976
+
977
+ if payload.user_id:
978
+ cursor = fsm.FSMCursor(self.storage, payload.user_id)
979
+ else:
980
+ cursor = None
981
+
982
+ # handling
983
+ for handler in self.handlers[update_type]:
984
+ filters = [filter(payload) for filter in handler.filters]
985
+
986
+ if all(filters):
987
+ kwargs = utils.context_kwargs(handler.call, cursor=cursor)
988
+ self._run_handler(
989
+ handler.call(payload, **kwargs),
990
+ user_id=cursor.user_id if cursor else None,
991
+ )
992
+
993
+ # handle logs
994
+ bot_logger.debug(f'Message "{payload.content}" deleted')
995
+
996
+ if update_type == "bot_started":
997
+ payload = BotStartPayload.from_json(update, self)
998
+ cursor = fsm.FSMCursor(self.storage, payload.user.user_id)
999
+
1000
+ bot_logger.debug(f'User "{payload.user!r}" started bot')
1001
+
1002
+ for i in self.handlers[update_type]:
1003
+ kwargs = utils.context_kwargs(i, cursor=cursor)
1004
+ self._run_handler(i(payload, **kwargs), user_id=cursor.user_id)
1005
+
1006
+ if update_type == "chat_title_changed":
1007
+ payload = ChatTitleEditPayload.from_json(update)
1008
+ cursor = fsm.FSMCursor(self.storage, payload.user.user_id)
1009
+
1010
+ bot_logger.debug(
1011
+ f'User "{payload.user!r} '
1012
+ f"changed title of chat {payload.chat_id}"
1013
+ )
1014
+
1015
+ for i in self.handlers[update_type]:
1016
+ kwargs = utils.context_kwargs(i, cursor=cursor)
1017
+ self._run_handler(i(payload, **kwargs), user_id=cursor.user_id)
1018
+
1019
+ if update_type == "bot_added" or update_type == "bot_removed":
1020
+ payload = ChatMembershipPayload.from_json(update)
1021
+ cursor = fsm.FSMCursor(self.storage, payload.user.user_id)
1022
+
1023
+ for i in self.handlers[update_type]:
1024
+ kwargs = utils.context_kwargs(i, cursor=cursor)
1025
+ self._run_handler(i(payload, **kwargs), user_id=cursor.user_id)
1026
+
1027
+ if update_type == "user_added" or update_type == "user_removed":
1028
+ payload = UserMembershipPayload.from_json(update)
1029
+ cursor = fsm.FSMCursor(self.storage, payload.user.user_id)
1030
+
1031
+ for i in self.handlers[update_type]:
1032
+ kwargs = utils.context_kwargs(i, cursor=cursor)
1033
+ self._run_handler(i(payload, **kwargs), user_id=cursor.user_id)
1034
+
1035
+ if update_type == "message_callback":
1036
+ handled = False
1037
+
1038
+ callback = Callback.from_json(
1039
+ update["callback"],
1040
+ update.get("message"),
1041
+ update.get("user_locale"),
1042
+ self,
1043
+ )
1044
+
1045
+ cursor = fsm.FSMCursor(self.storage, callback.user.user_id)
1046
+
1047
+ for handler in self.handlers[update_type]:
1048
+ filters = [filter(callback) for filter in handler.filters]
1049
+
1050
+ if all(filters):
1051
+ kwargs = utils.context_kwargs(handler.call, cursor=cursor)
1052
+ self._run_handler(
1053
+ handler.call(callback, **kwargs),
1054
+ user_id=cursor.user_id,
1055
+ )
1056
+ handled = True
1057
+
1058
+ if handled:
1059
+ bot_logger.debug(f'Callback "{callback.payload}" handled')
1060
+ else:
1061
+ bot_logger.debug(f'Callback "{callback.payload}" not handled')
1062
+
1063
+ if update_type == "message_chat_created":
1064
+ payload = ChatCreatePayload.from_json(update)
1065
+ bot_logger.debug(f'Created chat "{payload.start_payload}"')
1066
+
1067
+ for i in self.handlers[update_type]:
1068
+ self._run_handler(i(payload))
1069
+
1070
+ async def start_polling(
1071
+ self, session: "aiohttp.ClientSession | None" = None
1072
+ ):
1073
+ """
1074
+ Starts polling.
1075
+
1076
+ :param session: Custom aiohttp client session
1077
+ """
1078
+ self.polling = True
1079
+
1080
+ conn = None
1081
+
1082
+ if self.use_certificate:
1083
+ path = os.path.dirname(__file__) + "/russian_trusted_root_ca.cer"
1084
+ ssl_context = ssl.create_default_context()
1085
+ ssl_context.load_verify_locations(cafile=path)
1086
+ conn = aiohttp.TCPConnector(ssl=ssl_context)
1087
+
1088
+ if not session:
1089
+ session = aiohttp.ClientSession(
1090
+ headers={"Authorization": self.access_token},
1091
+ connector=conn,
1092
+ base_url=self.api_url,
1093
+ )
1094
+
1095
+ async with session:
1096
+ self.session = session
1097
+
1098
+ # self info (this will cache the info automatically)
1099
+ # also used to check the SSL certificate
1100
+ try:
1101
+ await self.get_me()
1102
+
1103
+ except ClientConnectorCertificateError as e:
1104
+ raise exceptions.InvalidSSLException(
1105
+ "Invalid SSL certificate. A Mintsifra certificate is now "
1106
+ "required to connect to the Max servers. You can set "
1107
+ "`use_certificate=True` when creating your `Bot` "
1108
+ "instance to use the embedded certificate if you do not "
1109
+ "wish to install the certificate system-wide."
1110
+ ) from e
1111
+
1112
+ bot_logger.info(
1113
+ f"Started polling with bot "
1114
+ f"@{self.username} ({self.id}) - {self.name}"
1115
+ )
1116
+
1117
+ # ready event
1118
+ for i in self.handlers["on_ready"]:
1119
+ self._run_handler(i())
1120
+
1121
+ while self.polling:
1122
+ try:
1123
+ updates = await self.get_updates()
1124
+
1125
+ for update in updates["updates"]:
1126
+ try:
1127
+ await self.handle_update(update)
1128
+ except Exception as e:
1129
+ # One malformed/unhandled update must not drop the
1130
+ # rest of the batch: the polling marker is already
1131
+ # committed in get_updates(), so a raise here would
1132
+ # lose every remaining update permanently.
1133
+ bot_logger.exception(e)
1134
+
1135
+ except Exception as e:
1136
+ bot_logger.exception(e)
1137
+ await asyncio.sleep(3)
1138
+
1139
+ except asyncio.exceptions.CancelledError:
1140
+ break # Python 3.9 throws an error when exit() is used
1141
+
1142
+ # Let running handlers finish before closing the session, but do
1143
+ # not block shutdown forever on a hung handler.
1144
+ if self._handler_tasks:
1145
+ _, pending = await asyncio.wait(
1146
+ set(self._handler_tasks),
1147
+ timeout=self.shutdown_timeout,
1148
+ )
1149
+ for task in pending:
1150
+ task.cancel()
1151
+ if pending:
1152
+ bot_logger.warning(
1153
+ "%d handler task(s) did not finish within %ss on "
1154
+ "shutdown and were cancelled",
1155
+ len(pending),
1156
+ self.shutdown_timeout,
1157
+ )
1158
+
1159
+ self.session = None
1160
+ self.polling = False
1161
+
1162
+ def run(self, *args, **kwargs):
1163
+ """
1164
+ Shortcut for `asyncio.run(Bot.start_polling())`
1165
+ """
1166
+ asyncio.run(self.start_polling(*args, **kwargs))