pyroflow 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 (44) hide show
  1. pyroflow/__init__.py +14 -0
  2. pyroflow/__meta__.py +1 -0
  3. pyroflow/client.py +413 -0
  4. pyroflow/dispatcher.py +503 -0
  5. pyroflow/enums.py +10 -0
  6. pyroflow/errors.py +20 -0
  7. pyroflow/listener_coordinator/__init__.py +11 -0
  8. pyroflow/listener_coordinator/listener_coordinator.py +151 -0
  9. pyroflow/listener_coordinator/memory_listener_coordinator.py +74 -0
  10. pyroflow/listener_coordinator/redis_listener_coordinator.py +280 -0
  11. pyroflow/models.py +64 -0
  12. pyroflow/types.py +304 -0
  13. pyroflow/typings.py +15 -0
  14. pyroflow/update_coordinated/__init__.py +11 -0
  15. pyroflow/update_coordinated/callback_query_coordinated.py +31 -0
  16. pyroflow/update_coordinated/message_coordinated.py +38 -0
  17. pyroflow/update_coordinated/update_coordinated.py +204 -0
  18. pyroflow/update_coordinator/__init__.py +11 -0
  19. pyroflow/update_coordinator/memory_update_coordinator.py +43 -0
  20. pyroflow/update_coordinator/redis_update_coordinator.py +54 -0
  21. pyroflow/update_coordinator/update_coordinator.py +118 -0
  22. pyroflow/update_history/__init__.py +12 -0
  23. pyroflow/update_history/callback_query_history.py +56 -0
  24. pyroflow/update_history/message_history.py +6 -0
  25. pyroflow/update_history/update_history.py +306 -0
  26. pyroflow/update_history_store/__init__.py +9 -0
  27. pyroflow/update_history_store/memory_update_history_store.py +117 -0
  28. pyroflow/update_history_store/update_history_store.py +93 -0
  29. pyroflow/update_listener/__init__.py +11 -0
  30. pyroflow/update_listener/callback_query_listener.py +82 -0
  31. pyroflow/update_listener/message_listener.py +48 -0
  32. pyroflow/update_listener/update_listener.py +391 -0
  33. pyroflow/utils/__init__.py +19 -0
  34. pyroflow/utils/async_tools.py +220 -0
  35. pyroflow/utils/classes.py +100 -0
  36. pyroflow/utils/enums.py +15 -0
  37. pyroflow/utils/iter_tools.py +30 -0
  38. pyroflow/utils/misc_tools.py +67 -0
  39. pyroflow/utils/typings.py +88 -0
  40. pyroflow/utils/validate_tools.py +30 -0
  41. pyroflow-0.1.0.dist-info/METADATA +271 -0
  42. pyroflow-0.1.0.dist-info/RECORD +44 -0
  43. pyroflow-0.1.0.dist-info/WHEEL +4 -0
  44. pyroflow-0.1.0.dist-info/licenses/LICENSE +21 -0
pyroflow/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ from .listener_coordinator import *
2
+ from .update_coordinated import *
3
+ from .update_coordinator import *
4
+ from .update_history import *
5
+ from .update_history_store import *
6
+ from .update_listener import *
7
+
8
+ from .client import Client
9
+ from .dispatcher import Dispatcher
10
+
11
+ from .types import Message, CallbackQuery
12
+
13
+
14
+
pyroflow/__meta__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
pyroflow/client.py ADDED
@@ -0,0 +1,413 @@
1
+ from typing import TYPE_CHECKING, Type, Dict, List, Optional, Union, overload
2
+ from datetime import datetime
3
+
4
+ from pyrogram import (
5
+ Client as PyroClient,
6
+ enums as pyro_enums,
7
+ types as pyro_types,
8
+ utils as pyro_ut
9
+ )
10
+
11
+ from .utils.typings import Number, JsonValueT, UpdateType
12
+ from .utils import patch_cls
13
+
14
+ from .types import Message, CallbackQuery
15
+
16
+ from .dispatcher import Dispatcher
17
+ from .update_listener import UpdateListener
18
+
19
+
20
+
21
+ @patch_cls
22
+ class Client(PyroClient):
23
+ """
24
+ The main entry point of the library and the only class you need to
25
+ interact with directly.
26
+
27
+ Extends :class:`pyrogram.Client` with a conversation-oriented API that
28
+ lets you ``await`` a specific reply from a specific user rather than
29
+ wiring up global handlers for every possible update.
30
+
31
+ Why this exists
32
+ ---------------
33
+ Pyrogram's built-in model is handler-based: you register a function and
34
+ it fires for *every* incoming update of that type. That works well for
35
+ broadcast-style bots, but breaks down the moment you need a back-and-forth
36
+ conversation — you end up managing state machines by hand just to know
37
+ whose answer you are waiting for.
38
+
39
+ This client solves that by integrating three systems from :class:`Dispatcher`
40
+ and exposing them through a single, convenient interface:
41
+
42
+ - **Listeners** — typed queues that let any coroutine ``await`` the next
43
+ update from a particular user/chat. An update claimed by a listener never
44
+ reaches the normal handler pipeline.
45
+
46
+ - **Coordinators** — distributed locks attached per update type that ensure
47
+ an update is processed by exactly one session when the bot runs on multiple
48
+ servers simultaneously.
49
+
50
+ - **Histories** — per-update-type records of which handlers ran successfully,
51
+ enabling features like ``back`` buttons that replay previous steps.
52
+
53
+ What this class provides
54
+ ------------------------
55
+ **Registration** — attach the above systems to the client before it starts:
56
+
57
+ .. code-block:: python
58
+
59
+ client.register_listener(MyMessageListener())
60
+ client.register_coordinated(MyCoordinated())
61
+ client.register_history(MyHistory())
62
+
63
+ **Shortcuts** — read-only properties for the two most common listeners:
64
+
65
+ .. code-block:: python
66
+
67
+ client.message_listen # UpdateListener[Message]
68
+ client.callback_listen # UpdateListener[CallbackQuery]
69
+
70
+ **``ask()``** — the core high-level method. Sends (or edits) a message and
71
+ then suspends until a matching reply arrives, all in one ``await``:
72
+
73
+ .. code-block:: python
74
+
75
+ answer = await client.ask(chat_id, "What is your name?",
76
+ listen_user_id=user_id, timeout=30)
77
+
78
+ Under the hood ``ask()`` calls :meth:`send_message` (or
79
+ :meth:`edit_message_text` when ``message_id`` is supplied), registers a
80
+ one-shot wait on the appropriate :class:`UpdateListener`, and returns the
81
+ matching update. A :exc:`ListenerTimeout` is raised if no reply arrives
82
+ within ``timeout`` seconds.
83
+
84
+ Note on ``@patch_cls``
85
+ ------------------
86
+ The class is decorated with ``@patch_cls``, which passes every ``async`` method
87
+ through ``async_to_sync``. This means all methods can also be called
88
+ synchronously from outside an event loop, matching the behaviour of the
89
+ standard :class:`pyrogram.Client`.
90
+
91
+ Examples
92
+ --------
93
+ **Using ``ask()``** — send a question and wait for the reply in one step:
94
+
95
+ .. code-block:: python
96
+
97
+ from pyroflow import Client, MessageListener
98
+
99
+ client = Client("my_session")
100
+ client.register_listener(MessageListener())
101
+
102
+ @client.on_message()
103
+ async def on_start(client, message):
104
+ if message.text != "/start":
105
+ return
106
+
107
+ answer = await message.ask(
108
+ text="What is your name?",
109
+ listen_user_id=message.from_user.id,
110
+ timeout=60,
111
+ )
112
+ await answer.reply(f"Hello, {answer.text}!")
113
+
114
+ client.run()
115
+
116
+ **Using ``listen`` directly** — wait for an update anywhere in your code
117
+ without sending a message first:
118
+
119
+ .. code-block:: python
120
+
121
+ from pyroflow import Client, MessageListener
122
+ from pyroflow.errors import ListenerTimeout
123
+
124
+ client = Client("my_session")
125
+ client.register_listener(MessageListener())
126
+
127
+ @client.on_message()
128
+ async def on_confirm(client, message):
129
+ if message.text != "/confirm":
130
+ return
131
+
132
+ await message.reply("Please send your confirmation code:")
133
+
134
+ try:
135
+ code_msg = await client.message_listen(
136
+ chat_id=message.chat.id,
137
+ user_id=message.from_user.id,
138
+ timeout=120,
139
+ )
140
+ except ListenerTimeout:
141
+ await message.reply("Timed out. Please try again.")
142
+ return
143
+
144
+ await code_msg.reply(f"Code received: {code_msg.text}")
145
+
146
+ client.run()
147
+ """
148
+
149
+ dispatcher: Dispatcher
150
+
151
+ @property
152
+ def listeners(self) -> Dict[Type[UpdateType], UpdateListener[UpdateType]]:
153
+ """
154
+ Mapping from each update type to its registered :class:`UpdateListener`.
155
+
156
+ The key is the update type (e.g. ``Message`` or ``CallbackQuery``),
157
+ and the value is the listener responsible for awaiting that type.
158
+ """
159
+ return self.dispatcher.listeners
160
+
161
+ @property
162
+ def message_listen(self) -> UpdateListener[Message]:
163
+ """Shortcut to the :class:`UpdateListener` registered for :class:`Message` updates."""
164
+ return self.listeners[Message]
165
+
166
+ @property
167
+ def callback_listen(self) -> UpdateListener[CallbackQuery]:
168
+ """Shortcut to the :class:`UpdateListener` registered for :class:`CallbackQuery` updates."""
169
+ return self.listeners[CallbackQuery]
170
+
171
+ if TYPE_CHECKING:
172
+ register_listener = Dispatcher.register_listener
173
+ register_coordinated = Dispatcher.register_coordinated
174
+ register_history = Dispatcher.register_history
175
+
176
+ unregister_listener = Dispatcher.unregister_listener
177
+ unregister_coordinated = Dispatcher.unregister_coordinated
178
+ unregister_history = Dispatcher.unregister_history
179
+
180
+ else:
181
+ def register_listener(self, value):
182
+ return self.dispatcher.register_listener(value)
183
+
184
+ def register_coordinated(self, value):
185
+ return self.dispatcher.register_coordinated(value)
186
+
187
+ def register_history(self, value):
188
+ return self.dispatcher.register_history(value)
189
+
190
+ async def unregister_listener(self, value):
191
+ return await self.dispatcher.unregister_listener(value)
192
+
193
+ async def unregister_coordinated(self, value):
194
+ return await self.dispatcher.unregister_coordinated(value)
195
+
196
+ async def unregister_history(self, value):
197
+ return await self.dispatcher.unregister_history(value)
198
+
199
+
200
+ @overload
201
+ async def ask(
202
+ self,
203
+ chat_id: Union[int, str],
204
+ text: str,
205
+ *,
206
+ parse_mode: Optional[pyro_enums.ParseMode] = None,
207
+ entities: Optional[List[pyro_types.MessageEntity]] = None,
208
+ link_preview_options: Optional[pyro_types.LinkPreviewOptions] = None,
209
+ reply_parameters: Optional[pyro_types.ReplyParameters] = None,
210
+ disable_notification: Optional[bool] = None,
211
+ message_thread_id: Optional[int] = None,
212
+ direct_messages_topic_id: Optional[int] = None,
213
+ effect_id: Optional[int] = None,
214
+ schedule_date: Optional[datetime] = None,
215
+ repeat_period: Optional[int] = None,
216
+ protect_content: Optional[bool] = None,
217
+ business_connection_id: Optional[str] = None,
218
+ allow_paid_broadcast: Optional[bool] = None,
219
+ paid_message_star_count: Optional[int] = None,
220
+ suggested_post_parameters: Optional[pyro_types.SuggestedPostParameters] = None,
221
+ reply_markup: Optional[Union[
222
+ pyro_types.InlineKeyboardMarkup,
223
+ pyro_types.ReplyKeyboardMarkup,
224
+ pyro_types.ReplyKeyboardRemove,
225
+ pyro_types.ForceReply,
226
+ ]] = None,
227
+ # listen params
228
+ listen_user_id: Optional[int] = None,
229
+ listen_message_id: Optional[int] = None,
230
+ meta: Optional[JsonValueT] = None,
231
+ timeout: Optional[Number] = None,
232
+ update_type: Type[UpdateType] = Message,
233
+ **kw
234
+ ) -> UpdateType: ...
235
+
236
+ @overload
237
+ async def ask(
238
+ self,
239
+ chat_id: Union[int, str],
240
+ text: str,
241
+ message_id: int,
242
+ *,
243
+ parse_mode: Optional[pyro_enums.ParseMode] = None,
244
+ entities: Optional[List[pyro_types.MessageEntity]] = None,
245
+ link_preview_options: Optional[pyro_types.LinkPreviewOptions] = None,
246
+ schedule_date: Optional[datetime] = None,
247
+ business_connection_id: Optional[str] = None,
248
+ reply_markup: Optional[pyro_types.InlineKeyboardMarkup] = None,
249
+ # listen params
250
+ listen_user_id: Optional[int] = None,
251
+ listen_message_id: Optional[int] = None,
252
+ meta: Optional[JsonValueT] = None,
253
+ timeout: Optional[Number] = None,
254
+ update_type: Type[UpdateType] = Message,
255
+ **kw
256
+ ) -> UpdateType: ...
257
+
258
+ async def ask(
259
+ self,
260
+ chat_id: Union[int, str],
261
+ text: str,
262
+ message_id: Optional[int] = None,
263
+ *,
264
+ parse_mode: Optional[pyro_enums.ParseMode] = None,
265
+ entities: Optional[List[pyro_types.MessageEntity]] = None,
266
+ link_preview_options: Optional[pyro_types.LinkPreviewOptions] = None,
267
+ schedule_date: Optional[datetime] = None,
268
+ business_connection_id: Optional[str] = None,
269
+ reply_markup: Optional[Union[
270
+ pyro_types.InlineKeyboardMarkup,
271
+ pyro_types.ReplyKeyboardMarkup,
272
+ pyro_types.ReplyKeyboardRemove,
273
+ pyro_types.ForceReply,
274
+ ]] = None,
275
+ # send_message only params
276
+ disable_notification: Optional[bool] = None,
277
+ message_thread_id: Optional[int] = None,
278
+ direct_messages_topic_id: Optional[int] = None,
279
+ effect_id: Optional[int] = None,
280
+ reply_parameters: Optional[pyro_types.ReplyParameters] = None,
281
+ repeat_period: Optional[int] = None,
282
+ protect_content: Optional[bool] = None,
283
+ allow_paid_broadcast: Optional[bool] = None,
284
+ paid_message_star_count: Optional[int] = None,
285
+ suggested_post_parameters: Optional[pyro_types.SuggestedPostParameters] = None,
286
+ # listen params
287
+ listen_user_id: Optional[int] = None,
288
+ listen_message_id: Optional[int] = None,
289
+ meta: Optional[JsonValueT] = None,
290
+ timeout: Optional[Number] = None,
291
+ update_type: Type[UpdateType] = Message,
292
+ **kw
293
+ ) -> UpdateType:
294
+ """
295
+ Send or edit a message, then wait for a matching update.
296
+
297
+ If ``message_id`` is provided, the existing message is edited via
298
+ :meth:`edit_message_text`. Otherwise a new message is sent via
299
+ :meth:`send_message`.
300
+
301
+ After sending or editing, the method waits for the next update of
302
+ ``update_type`` matching the given ``chat_id``, ``listen_user_id``, and
303
+ ``listen_message_id``.
304
+
305
+ Parameters:
306
+ chat_id: Target chat.
307
+ text: Message text.
308
+ message_id: If provided, edit this message instead
309
+ of sending a new one.
310
+ parse_mode: Text parse mode.
311
+ entities: Message entities.
312
+ link_preview_options: Link preview settings.
313
+ schedule_date: Schedule the message.
314
+ business_connection_id: Business connection id.
315
+ reply_markup: Keyboard markup.
316
+ disable_notification: Send silently (send only).
317
+ message_thread_id: Thread id (send only).
318
+ direct_messages_topic_id: DM topic id (send only).
319
+ effect_id: Message effect (send only).
320
+ reply_parameters: Reply parameters (send only).
321
+ repeat_period: Repeat period (send only).
322
+ protect_content: Protect content (send only).
323
+ allow_paid_broadcast: Allow paid broadcast (send only).
324
+ paid_message_star_count: Paid message star count (send only).
325
+ suggested_post_parameters: Suggested post parameters (send only).
326
+
327
+ listen_user_id: Filter the awaited update by user.
328
+ listen_message_id: Filter the awaited update by message.
329
+ meta: Metadata attached to the listener.
330
+ timeout: Seconds to wait before raising
331
+ :class:`ListenerTimeout`.
332
+ update_type: The update type to wait for.
333
+ Determines the return type.
334
+ **kw: Additional keyword arguments passed directly to
335
+ :meth:`send_message` or :meth:`edit_message_text`
336
+ depending on which operation is performed.
337
+
338
+ Returns:
339
+ The matching update of type ``update_type``.
340
+
341
+ Raises:
342
+ ListenerTimeout: If the timeout expires before the update arrives.
343
+ ListenerCancelled: If the listener is cancelled while waiting.
344
+ """
345
+
346
+ listener = self.listeners.get(update_type)
347
+
348
+ if listener is None:
349
+ raise RuntimeError(
350
+ f"No listener registered for {update_type.__name__}. "
351
+ f"Register one via client.register_listener() before calling ask()."
352
+ )
353
+
354
+
355
+ if message_id is not None:
356
+ if reply_markup is not None and not isinstance(reply_markup, pyro_types.InlineKeyboardMarkup):
357
+ raise ValueError("Edit mode only supports InlineKeyboardMarkup for reply_markup")
358
+
359
+ m = await self.edit_message_text(
360
+ chat_id=chat_id,
361
+ message_id=message_id,
362
+ text=text,
363
+ parse_mode=parse_mode,
364
+ entities=entities,
365
+ link_preview_options=link_preview_options,
366
+ schedule_date=schedule_date,
367
+ business_connection_id=business_connection_id,
368
+ reply_markup=reply_markup,
369
+ **kw
370
+ )
371
+ else:
372
+ m = await self.send_message(
373
+ chat_id=chat_id,
374
+ text=text,
375
+ parse_mode=parse_mode,
376
+ entities=entities,
377
+ link_preview_options=link_preview_options,
378
+ disable_notification=disable_notification,
379
+ message_thread_id=message_thread_id,
380
+ direct_messages_topic_id=direct_messages_topic_id,
381
+ effect_id=effect_id,
382
+ reply_parameters=reply_parameters,
383
+ schedule_date=schedule_date,
384
+ repeat_period=repeat_period,
385
+ protect_content=protect_content,
386
+ business_connection_id=business_connection_id,
387
+ allow_paid_broadcast=allow_paid_broadcast,
388
+ paid_message_star_count=paid_message_star_count,
389
+ suggested_post_parameters=suggested_post_parameters,
390
+ reply_markup=reply_markup,
391
+ **kw,
392
+ )
393
+
394
+
395
+ if meta is None or isinstance(meta, dict):
396
+ meta = {} if meta is None else meta.copy()
397
+ if "message_id" not in meta and m:
398
+ meta["message_id"] = m.id
399
+
400
+ final_chat_id = m.chat.id if (m and m.chat) else chat_id
401
+
402
+ if isinstance(final_chat_id, str):
403
+ final_chat_id = pyro_ut.get_peer_id(
404
+ await self.resolve_peer(final_chat_id)
405
+ )
406
+
407
+ return await listener(
408
+ chat_id=final_chat_id,
409
+ user_id=listen_user_id,
410
+ message_id=listen_message_id,
411
+ meta=meta,
412
+ timeout=timeout,
413
+ )