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/__init__.py +9 -0
- aiomax/bot.py +1166 -0
- aiomax/buttons.py +303 -0
- aiomax/cache.py +31 -0
- aiomax/exceptions.py +85 -0
- aiomax/filters.py +179 -0
- aiomax/fsm.py +107 -0
- aiomax/router.py +383 -0
- aiomax/russian_trusted_root_ca.cer +33 -0
- aiomax/types.py +1512 -0
- aiomax/utils.py +125 -0
- maxkit-2.13.0.dist-info/METADATA +45 -0
- maxkit-2.13.0.dist-info/RECORD +16 -0
- maxkit-2.13.0.dist-info/WHEEL +5 -0
- maxkit-2.13.0.dist-info/licenses/LICENSE.md +22 -0
- maxkit-2.13.0.dist-info/top_level.txt +1 -0
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))
|