maxion 0.1.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 (50) hide show
  1. maxion/__init__.py +82 -0
  2. maxion/client.py +508 -0
  3. maxion/enums.py +125 -0
  4. maxion/errors.py +73 -0
  5. maxion/filters.py +228 -0
  6. maxion/handlers.py +97 -0
  7. maxion/parser.py +103 -0
  8. maxion/raw/__init__.py +125 -0
  9. maxion/raw/client.py +552 -0
  10. maxion/raw/const.py +70 -0
  11. maxion/raw/device.py +247 -0
  12. maxion/raw/enums.py +222 -0
  13. maxion/raw/errors.py +100 -0
  14. maxion/raw/events.py +349 -0
  15. maxion/raw/filters.py +213 -0
  16. maxion/raw/methods/__init__.py +47 -0
  17. maxion/raw/methods/assets.py +139 -0
  18. maxion/raw/methods/auth.py +438 -0
  19. maxion/raw/methods/base.py +39 -0
  20. maxion/raw/methods/calls.py +218 -0
  21. maxion/raw/methods/chats.py +689 -0
  22. maxion/raw/methods/contacts.py +172 -0
  23. maxion/raw/methods/media.py +272 -0
  24. maxion/raw/methods/messages.py +471 -0
  25. maxion/raw/methods/misc.py +54 -0
  26. maxion/raw/methods/profile.py +131 -0
  27. maxion/raw/methods/reactions.py +94 -0
  28. maxion/raw/methods/stories.py +214 -0
  29. maxion/raw/opcodes.py +269 -0
  30. maxion/raw/protocol.py +190 -0
  31. maxion/raw/router.py +135 -0
  32. maxion/raw/session.py +84 -0
  33. maxion/raw/transport/__init__.py +17 -0
  34. maxion/raw/transport/base.py +47 -0
  35. maxion/raw/transport/tcp.py +105 -0
  36. maxion/raw/transport/ws.py +100 -0
  37. maxion/raw/types/__init__.py +66 -0
  38. maxion/raw/types/attach.py +240 -0
  39. maxion/raw/types/base.py +98 -0
  40. maxion/raw/types/chat.py +239 -0
  41. maxion/raw/types/message.py +210 -0
  42. maxion/raw/types/misc.py +192 -0
  43. maxion/raw/types/user.py +198 -0
  44. maxion/raw/utils.py +236 -0
  45. maxion/types.py +447 -0
  46. maxion-0.1.0.dist-info/METADATA +355 -0
  47. maxion-0.1.0.dist-info/RECORD +50 -0
  48. maxion-0.1.0.dist-info/WHEEL +5 -0
  49. maxion-0.1.0.dist-info/licenses/LICENSE +21 -0
  50. maxion-0.1.0.dist-info/top_level.txt +1 -0
maxion/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """maxion — библиотека-клиент мессенджера MAX.
2
+
3
+ Работа от лица обычного аккаунта (юзербот), а не через Bot API::
4
+
5
+ from maxion import Client, filters
6
+
7
+ app = Client("my_account", phone_number="+79991234567")
8
+
9
+ @app.on_message(filters.command("start") & filters.private)
10
+ async def start(client, message):
11
+ await message.reply("привет")
12
+
13
+ app.run()
14
+
15
+ Слоя два:
16
+
17
+ * удобный — :class:`Client`, :mod:`maxion.filters`, :mod:`maxion.types`:
18
+ обработчики с фильтрами, модели с связанными методами, разметка текста;
19
+ * низкий — :mod:`maxion.raw`: все 153 опкода внутреннего протокола, кадры и
20
+ транспорты, сверенные с APK ``ru.oneme.app`` 26.29.1. Доступен как
21
+ ``app.raw``, произвольный опкод — через ``app.invoke()``.
22
+ """
23
+
24
+ from . import enums, errors, filters, handlers, raw, types
25
+ from .client import Client, ContinuePropagation, StopPropagation, compose, idle
26
+ from .enums import (
27
+ ChatAction,
28
+ ChatMemberStatus,
29
+ ChatType,
30
+ MessageEntityType,
31
+ ParseMode,
32
+ UserStatus,
33
+ )
34
+ from .errors import FloodWait, RPCError, SessionPasswordNeeded, Unauthorized
35
+ from .handlers import (
36
+ DeletedMessagesHandler,
37
+ EditedMessageHandler,
38
+ MessageHandler,
39
+ RawUpdateHandler,
40
+ )
41
+ from .types import Chat, ChatMember, Dialog, Message, MessageEntity, User
42
+
43
+ __version__ = "0.1.0"
44
+
45
+ __all__ = [
46
+ "Client",
47
+ "idle",
48
+ "compose",
49
+ "StopPropagation",
50
+ "ContinuePropagation",
51
+ # модули
52
+ "filters",
53
+ "types",
54
+ "enums",
55
+ "errors",
56
+ "handlers",
57
+ "raw",
58
+ # типы
59
+ "User",
60
+ "Chat",
61
+ "Message",
62
+ "MessageEntity",
63
+ "ChatMember",
64
+ "Dialog",
65
+ # перечисления
66
+ "ChatType",
67
+ "ParseMode",
68
+ "ChatAction",
69
+ "MessageEntityType",
70
+ "UserStatus",
71
+ "ChatMemberStatus",
72
+ # ошибки
73
+ "RPCError",
74
+ "FloodWait",
75
+ "Unauthorized",
76
+ "SessionPasswordNeeded",
77
+ # обработчики
78
+ "MessageHandler",
79
+ "EditedMessageHandler",
80
+ "DeletedMessagesHandler",
81
+ "RawUpdateHandler",
82
+ ]
maxion/client.py ADDED
@@ -0,0 +1,508 @@
1
+ """Client — высокоуровневый клиент MAX."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import logging
8
+ import os
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+ from typing import Any, AsyncIterator, Callable, Sequence
12
+
13
+ from .enums import ChatAction, ChatMemberStatus, ParseMode
14
+ from .filters import Filter
15
+ from .handlers import (
16
+ DeletedMessagesHandler,
17
+ EditedMessageHandler,
18
+ Handler,
19
+ MessageHandler,
20
+ RawUpdateHandler,
21
+ )
22
+ from .parser import parse
23
+ from .raw.client import MaxClient
24
+ from .raw.device import Device
25
+ from .types import Chat, ChatMember, Dialog, Message, User
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ class StopPropagation(Exception):
31
+ """Прекратить обработку события целиком."""
32
+
33
+
34
+ class ContinuePropagation(Exception):
35
+ """Передать событие следующему обработчику в той же группе."""
36
+
37
+
38
+ class Client:
39
+ """Высокоуровневый клиент MAX.
40
+
41
+ ::
42
+
43
+ app = Client("my_account", phone_number="+79991234567")
44
+
45
+ @app.on_message(filters.command("start") & filters.private)
46
+ async def start(client, message):
47
+ await message.reply("привет")
48
+
49
+ app.run()
50
+
51
+ Низкоуровневый клиент со всеми 153 опкодами доступен как :attr:`raw`.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ name: str = "my_account",
57
+ *,
58
+ phone_number: str | None = None,
59
+ password: str | None = None,
60
+ workdir: str | os.PathLike[str] = ".",
61
+ transport: str = "tcp",
62
+ device_model: str | None = None,
63
+ app_version: str | None = None,
64
+ parse_mode: ParseMode = ParseMode.DEFAULT,
65
+ device: Device | str | None = None,
66
+ **kwargs: Any,
67
+ ):
68
+ self.name = name
69
+ self.phone_number = phone_number
70
+ self.password = password
71
+ self.workdir = Path(workdir)
72
+ self.parse_mode = parse_mode
73
+
74
+ if device is None:
75
+ device = Device.for_transport(
76
+ "ANDROID" if transport in ("tcp", "tls", "mobile", "app") else "WEB"
77
+ )
78
+ elif isinstance(device, str):
79
+ device = Device.for_transport(device.upper())
80
+ if device_model:
81
+ device.device_name = device_model
82
+ if app_version:
83
+ device.app_version = app_version
84
+
85
+ self._raw = MaxClient(
86
+ self.workdir / f"{name}.session",
87
+ transport=transport,
88
+ device=device,
89
+ **kwargs,
90
+ )
91
+ self._groups: dict[int, list[Handler]] = defaultdict(list)
92
+ self._started = False
93
+
94
+ self._raw.router.on("raw")(self._feed)
95
+
96
+ # --- доступ к слоям --------------------------------------------------
97
+
98
+ @property
99
+ def raw(self) -> MaxClient:
100
+ """Низкоуровневый клиент: все опкоды, транспорт, сессия."""
101
+ return self._raw
102
+
103
+ @property
104
+ def me(self) -> User | None:
105
+ """Свой профиль, если уже вошли."""
106
+ return User(self, self._raw.me) if self._raw.me else None
107
+
108
+ @property
109
+ def is_connected(self) -> bool:
110
+ return self._raw.is_connected
111
+
112
+ # --- жизненный цикл ---------------------------------------------------
113
+
114
+ async def start(self) -> "Client":
115
+ """Подключается и логинится; повторный вызов безвреден."""
116
+ if self._started:
117
+ return self
118
+ await self._raw.connect()
119
+ await self._raw.start(phone=self.phone_number, password=self.password)
120
+ self._started = True
121
+ log.info("Вошли как %s", self._raw.me.name if self._raw.me else "?")
122
+ return self
123
+
124
+ async def stop(self) -> "Client":
125
+ await self._raw.disconnect()
126
+ self._started = False
127
+ return self
128
+
129
+ async def __aenter__(self) -> "Client":
130
+ return await self.start()
131
+
132
+ async def __aexit__(self, *exc_info) -> None:
133
+ await self.stop()
134
+
135
+ def run(self, coroutine=None) -> Any:
136
+ """Блокирующий запуск: поднимает клиент и держит его до остановки."""
137
+
138
+ async def main():
139
+ await self.start()
140
+ try:
141
+ if coroutine is not None:
142
+ return await coroutine
143
+ await self._raw.run_until_disconnected()
144
+ finally:
145
+ if self._started:
146
+ await self.stop()
147
+
148
+ return asyncio.run(main())
149
+
150
+ async def idle(self) -> None:
151
+ """Ждёт, пока соединение живо."""
152
+ await self._raw.run_until_disconnected()
153
+
154
+ # --- обработчики ------------------------------------------------------
155
+
156
+ def add_handler(self, handler: Handler, group: int = 0) -> Handler:
157
+ """Регистрирует обработчик в группе (меньше — раньше)."""
158
+ self._groups[group].append(handler)
159
+ return handler
160
+
161
+ def remove_handler(self, handler: Handler, group: int = 0) -> None:
162
+ if handler in self._groups.get(group, []):
163
+ self._groups[group].remove(handler)
164
+
165
+ def _decorator(self, handler_cls: type[Handler]):
166
+ def outer(filters: Filter | None = None, group: int = 0):
167
+ def decorator(func):
168
+ self.add_handler(handler_cls(func, filters), group)
169
+ return func
170
+
171
+ return decorator
172
+
173
+ return outer
174
+
175
+ def on_message(self, filters: Filter | None = None, group: int = 0):
176
+ return self._decorator(MessageHandler)(filters, group)
177
+
178
+ def on_edited_message(self, filters: Filter | None = None, group: int = 0):
179
+ return self._decorator(EditedMessageHandler)(filters, group)
180
+
181
+ def on_deleted_messages(self, filters: Filter | None = None, group: int = 0):
182
+ return self._decorator(DeletedMessagesHandler)(filters, group)
183
+
184
+ def on_raw_update(self, filters: Filter | None = None, group: int = 0):
185
+ return self._decorator(RawUpdateHandler)(filters, group)
186
+
187
+ # --- диспетчер --------------------------------------------------------
188
+
189
+ async def _feed(self, update) -> None:
190
+ """Принимает событие низкоуровневого роутера и раздаёт обработчикам."""
191
+ event = update.event
192
+ payload: Any = update
193
+ if event == "message":
194
+ payload = Message(self, update.message)
195
+ if getattr(update, "is_edit", False):
196
+ event = "edited_message"
197
+
198
+ for group in sorted(self._groups):
199
+ for handler in self._groups[group]:
200
+ if handler.event not in (event, "raw"):
201
+ continue
202
+ target = update if handler.event == "raw" else payload
203
+ try:
204
+ if not await handler.check(self, target):
205
+ continue
206
+ except Exception:
207
+ log.exception("Ошибка в фильтре %s", handler)
208
+ continue
209
+ try:
210
+ await handler.callback(self, target)
211
+ except ContinuePropagation:
212
+ continue
213
+ except StopPropagation:
214
+ return
215
+ except Exception:
216
+ log.exception("Ошибка в обработчике %s", handler)
217
+ break # в группе срабатывает первый подошедший
218
+
219
+ # --- сообщения --------------------------------------------------------
220
+
221
+ def _parse(self, text: str, parse_mode: ParseMode | str | None):
222
+ return parse(text, parse_mode if parse_mode is not None else self.parse_mode)
223
+
224
+ async def send_message(
225
+ self,
226
+ chat_id: int,
227
+ text: str,
228
+ *,
229
+ parse_mode: ParseMode | str | None = None,
230
+ entities: Sequence[dict[str, Any]] | None = None,
231
+ reply_to_message_id: str | None = None,
232
+ disable_notification: bool = False,
233
+ **kwargs: Any,
234
+ ) -> Message:
235
+ """Отправляет текстовое сообщение."""
236
+ body, parsed = self._parse(text, parse_mode)
237
+ raw = await self._raw.send_message(
238
+ chat_id,
239
+ body,
240
+ elements=list(entities) if entities is not None else parsed,
241
+ reply_to=reply_to_message_id,
242
+ notify=not disable_notification,
243
+ **kwargs,
244
+ )
245
+ return Message(self, raw)
246
+
247
+ async def edit_message_text(
248
+ self,
249
+ chat_id: int,
250
+ message_id: str,
251
+ text: str,
252
+ *,
253
+ parse_mode: ParseMode | str | None = None,
254
+ **kwargs: Any,
255
+ ) -> Message:
256
+ body, parsed = self._parse(text, parse_mode)
257
+ raw = await self._raw.edit_message(
258
+ chat_id, message_id, body, elements=parsed, **kwargs
259
+ )
260
+ return Message(self, raw)
261
+
262
+ async def delete_messages(
263
+ self, chat_id: int, message_ids: Sequence[str] | str, *, revoke: bool = True
264
+ ) -> None:
265
+ """``revoke=True`` удаляет у всех, ``False`` — только у себя."""
266
+ await self._raw.delete_messages(chat_id, message_ids, for_me=not revoke)
267
+
268
+ async def forward_messages(
269
+ self, chat_id: int, from_chat_id: int, message_ids: Sequence[str] | str
270
+ ) -> list[Message]:
271
+ if isinstance(message_ids, str):
272
+ message_ids = [message_ids]
273
+ raw = await self._raw.forward_messages(chat_id, from_chat_id, message_ids)
274
+ return [Message(self, item) for item in raw]
275
+
276
+ async def get_messages(
277
+ self, chat_id: int, message_ids: Sequence[str] | str
278
+ ) -> Message | list[Message]:
279
+ single = isinstance(message_ids, str)
280
+ raw = await self._raw.get_messages(chat_id, message_ids)
281
+ messages = [Message(self, item) for item in raw]
282
+ if single:
283
+ return messages[0] if messages else None # type: ignore[return-value]
284
+ return messages
285
+
286
+ async def get_chat_history(
287
+ self, chat_id: int, *, limit: int = 100, offset_date: int | None = None
288
+ ) -> AsyncIterator[Message]:
289
+ """Асинхронный генератор сообщений — от новых к старым."""
290
+ produced = 0
291
+ async for raw in self._raw.iter_history(chat_id, limit=limit):
292
+ if offset_date is not None and (raw.get("time") or 0) > offset_date:
293
+ continue
294
+ yield Message(self, raw)
295
+ produced += 1
296
+ if produced >= limit:
297
+ return
298
+
299
+ async def search_messages(
300
+ self, query: str, *, chat_id: int | None = None, limit: int = 30
301
+ ) -> dict[str, Any]:
302
+ return await self._raw.search_messages(query, chat_id=chat_id, count=limit)
303
+
304
+ async def read_chat_history(self, chat_id: int, message_id: str) -> None:
305
+ await self._raw.read_message(chat_id, message_id)
306
+
307
+ async def send_reaction(
308
+ self, chat_id: int, message_id: str, emoji: str = "❤️"
309
+ ) -> Any:
310
+ return await self._raw.set_reaction(chat_id, message_id, emoji)
311
+
312
+ async def send_chat_action(
313
+ self, chat_id: int, action: ChatAction | str = ChatAction.TYPING
314
+ ) -> None:
315
+ raw = action.raw if isinstance(action, ChatAction) else str(action)
316
+ await self._raw.send_typing(chat_id, raw)
317
+
318
+ # --- медиа ------------------------------------------------------------
319
+
320
+ async def send_photo(
321
+ self, chat_id: int, photo, caption: str = "", **kwargs: Any
322
+ ) -> Message:
323
+ return Message(self, await self._raw.send_photo(chat_id, photo, caption, **kwargs))
324
+
325
+ async def send_video(
326
+ self, chat_id: int, video, caption: str = "", **kwargs: Any
327
+ ) -> Message:
328
+ return Message(self, await self._raw.send_video(chat_id, video, caption, **kwargs))
329
+
330
+ async def send_document(
331
+ self, chat_id: int, document, caption: str = "", **kwargs: Any
332
+ ) -> Message:
333
+ return Message(self, await self._raw.send_file(chat_id, document, caption, **kwargs))
334
+
335
+ async def send_sticker(self, chat_id: int, sticker_id: int, **kwargs: Any) -> Message:
336
+ return Message(self, await self._raw.send_sticker(chat_id, sticker_id, **kwargs))
337
+
338
+ async def download_media(
339
+ self, message: Message, *, file_name: str | os.PathLike[str] | None = None
340
+ ) -> Path | None:
341
+ """Скачивает первое вложение сообщения."""
342
+ attach = None
343
+ for candidate in message._raw.attaches:
344
+ if candidate.type in ("PHOTO", "VIDEO", "FILE", "AUDIO"):
345
+ attach = candidate
346
+ break
347
+ if attach is None:
348
+ return None
349
+
350
+ chat_id, message_id = message._raw.chat_id, message.id
351
+ if attach.type == "FILE":
352
+ url = await self._raw.get_file_url(chat_id, message_id, attach.file_id)
353
+ default = attach.name or f"{attach.file_id}.bin"
354
+ elif attach.type == "VIDEO":
355
+ urls = await self._raw.get_video_url(chat_id, message_id, attach.video_id)
356
+ url = next(iter(urls.values()), None)
357
+ default = f"{attach.video_id}.mp4"
358
+ elif attach.type == "AUDIO":
359
+ data = await self._raw.get_audio_url(chat_id, message_id, attach.audio_id)
360
+ url = data.get("url")
361
+ default = f"{attach.audio_id}.ogg"
362
+ else:
363
+ url, default = attach.url, f"{attach.photo_id}.jpg"
364
+ if not url:
365
+ return None
366
+ return await self._raw.download(url, file_name or default)
367
+
368
+ # --- чаты -------------------------------------------------------------
369
+
370
+ async def get_chat(self, chat_id: int) -> Chat | None:
371
+ raw = await self._raw.get_chat(chat_id)
372
+ return Chat(self, raw) if raw else None
373
+
374
+ async def get_dialogs(self, *, limit: int | None = None) -> AsyncIterator[Dialog]:
375
+ async for raw in self._raw.iter_chats(limit=limit):
376
+ yield Dialog(self, raw)
377
+
378
+ async def join_chat(self, link: str) -> Chat | None:
379
+ raw = await self._raw.join_chat(link)
380
+ return Chat(self, raw) if raw else None
381
+
382
+ async def leave_chat(self, chat_id: int) -> None:
383
+ await self._raw.leave_chat(chat_id)
384
+
385
+ async def set_chat_title(self, chat_id: int, title: str) -> None:
386
+ await self._raw.set_chat_title(chat_id, title)
387
+
388
+ async def set_chat_description(self, chat_id: int, description: str) -> None:
389
+ await self._raw.set_chat_description(chat_id, description)
390
+
391
+ async def pin_chat_message(
392
+ self, chat_id: int, message_id: str, *, disable_notification: bool = False
393
+ ) -> None:
394
+ await self._raw.pin_message(chat_id, message_id, notify=not disable_notification)
395
+
396
+ async def unpin_chat_message(self, chat_id: int) -> None:
397
+ await self._raw.unpin_message(chat_id)
398
+
399
+ async def get_chat_members(
400
+ self, chat_id: int, *, limit: int = 200, query: str | None = None
401
+ ) -> list[ChatMember]:
402
+ raw = await self._raw.get_members(chat_id, count=limit, query=query)
403
+ return [ChatMember(self, item) for item in raw]
404
+
405
+ async def add_chat_members(
406
+ self, chat_id: int, user_ids: Sequence[int] | int
407
+ ) -> None:
408
+ await self._raw.add_members(chat_id, user_ids)
409
+
410
+ async def ban_chat_member(self, chat_id: int, user_id: int) -> None:
411
+ await self._raw.block_members(chat_id, user_id)
412
+
413
+ async def unban_chat_member(self, chat_id: int, user_id: int) -> None:
414
+ await self._raw.update_members(chat_id, user_id, "unblock")
415
+
416
+ async def promote_chat_member(
417
+ self, chat_id: int, user_id: int, *, permissions: list[str] | None = None
418
+ ) -> None:
419
+ await self._raw.promote_members(chat_id, user_id, permissions)
420
+
421
+ async def demote_chat_member(self, chat_id: int, user_id: int) -> None:
422
+ await self._raw.demote_members(chat_id, user_id)
423
+
424
+ async def get_chat_member(self, chat_id: int, user_id: int) -> ChatMember | None:
425
+ for member in await self.get_chat_members(chat_id):
426
+ if member.user_id == user_id:
427
+ return member
428
+ return None
429
+
430
+ # --- пользователи ------------------------------------------------------
431
+
432
+ async def get_me(self) -> User | None:
433
+ raw = await self._raw.get_me()
434
+ return User(self, raw) if raw else None
435
+
436
+ async def get_users(
437
+ self, user_ids: Sequence[int] | int
438
+ ) -> User | list[User]:
439
+ single = isinstance(user_ids, int)
440
+ raw = await self._raw.get_contacts(user_ids)
441
+ users = [User(self, item) for item in raw]
442
+ if single:
443
+ return users[0] if users else None # type: ignore[return-value]
444
+ return users
445
+
446
+ async def get_contacts(self) -> list[User]:
447
+ return [User(self, raw) async for raw in self._raw.iter_contacts()]
448
+
449
+ async def block_user(self, user_id: int) -> None:
450
+ await self._raw.block_user(user_id)
451
+
452
+ async def unblock_user(self, user_id: int) -> None:
453
+ await self._raw.unblock_user(user_id)
454
+
455
+ async def resolve_phone(self, phone: str) -> User | None:
456
+ """Ищет пользователя по номеру телефона."""
457
+ raw = await self._raw.get_contact_by_phone(phone)
458
+ return User(self, raw) if raw else None
459
+
460
+ async def set_profile_name(
461
+ self, first_name: str, last_name: str | None = None
462
+ ) -> User:
463
+ raw = await self._raw.update_profile(
464
+ first_name=first_name, last_name=last_name
465
+ )
466
+ return User(self, raw)
467
+
468
+ # --- прочее -----------------------------------------------------------
469
+
470
+ async def invoke(self, opcode, payload: dict[str, Any] | None = None) -> dict[str, Any]:
471
+ """Вызывает произвольный опкод протокола напрямую."""
472
+ return await self._raw.call(opcode, payload)
473
+
474
+ def __repr__(self) -> str:
475
+ state = "запущен" if self._started else "остановлен"
476
+ return f"<Client {self.name!r} {state}>"
477
+
478
+
479
+ async def idle() -> None:
480
+ """Блокируется до Ctrl+C."""
481
+ stop = asyncio.Event()
482
+ loop = asyncio.get_running_loop()
483
+ import signal
484
+
485
+ for sig in (getattr(signal, "SIGINT", None), getattr(signal, "SIGTERM", None)):
486
+ if sig is None:
487
+ continue
488
+ try:
489
+ loop.add_signal_handler(sig, stop.set)
490
+ except (NotImplementedError, RuntimeError): # Windows
491
+ pass
492
+ await stop.wait()
493
+
494
+
495
+ def compose(clients: Sequence[Client]) -> Any:
496
+ """Запускает несколько клиентов разом и ждёт остановки."""
497
+
498
+ async def main():
499
+ await asyncio.gather(*(client.start() for client in clients))
500
+ try:
501
+ await idle()
502
+ finally:
503
+ await asyncio.gather(*(client.stop() for client in clients))
504
+
505
+ return asyncio.run(main())
506
+
507
+
508
+ __all__ = ["Client", "idle", "compose", "StopPropagation", "ContinuePropagation"]