pyteleflow 0.1.1__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.
- pyteleflow/__init__.py +4 -0
- pyteleflow/_app.py +439 -0
- pyteleflow/_flow.py +117 -0
- pyteleflow/_helpers.py +56 -0
- pyteleflow/_strategy.py +121 -0
- pyteleflow/_types.py +8 -0
- pyteleflow/errors.py +21 -0
- pyteleflow/filters_ext.py +51 -0
- pyteleflow/middleware.py +17 -0
- pyteleflow/py.typed +0 -0
- pyteleflow/widgets/__init__.py +2 -0
- pyteleflow/widgets/paged_select.py +176 -0
- pyteleflow/widgets/select.py +150 -0
- pyteleflow-0.1.1.dist-info/METADATA +258 -0
- pyteleflow-0.1.1.dist-info/RECORD +17 -0
- pyteleflow-0.1.1.dist-info/WHEEL +4 -0
- pyteleflow-0.1.1.dist-info/licenses/LICENSE +21 -0
pyteleflow/__init__.py
ADDED
pyteleflow/_app.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import asyncio as aio
|
|
2
|
+
import logging
|
|
3
|
+
import platform
|
|
4
|
+
import secrets
|
|
5
|
+
import signal
|
|
6
|
+
import time
|
|
7
|
+
from asyncio import Lock, Queue, Task
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable, Coroutine, Sequence
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import telegram.error
|
|
15
|
+
from telegram import Update
|
|
16
|
+
from telegram.constants import UpdateType
|
|
17
|
+
from telegram.ext import ExtBot, filters
|
|
18
|
+
from telegram.ext.filters import BaseFilter
|
|
19
|
+
from tenacity import Retrying, retry_if_exception_type, wait_exponential
|
|
20
|
+
|
|
21
|
+
from ._helpers import cancel_tasks
|
|
22
|
+
from ._flow import Context, Flow
|
|
23
|
+
from ._helpers import TaskStatus, get_task_status
|
|
24
|
+
from ._strategy import BaseConcurrencyStrategy, ConcurrencyStrategy
|
|
25
|
+
from ._types import FlowKey
|
|
26
|
+
from .errors import (
|
|
27
|
+
FlowAlreadySpawned,
|
|
28
|
+
FlowCancellationFailed,
|
|
29
|
+
FlowKeyError,
|
|
30
|
+
UnexpectedError,
|
|
31
|
+
PyteleflowError,
|
|
32
|
+
)
|
|
33
|
+
from .middleware import AsyncMiddleware, Middleware
|
|
34
|
+
|
|
35
|
+
_logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
type FlowCallback = Callable[[Context], Coroutine[Any, Any, None]]
|
|
39
|
+
|
|
40
|
+
DEFAULT_SPAWN_TRIGGER = filters.Text(["/start"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FlowApp:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
bot: ExtBot,
|
|
47
|
+
entry_coro: FlowCallback,
|
|
48
|
+
concurrency_limit: int | None = None,
|
|
49
|
+
spawn_trigger: BaseFilter = DEFAULT_SPAWN_TRIGGER, # Default: "/start" command
|
|
50
|
+
respawn_trigger: BaseFilter | None = DEFAULT_SPAWN_TRIGGER,
|
|
51
|
+
concurrency_strategy: BaseConcurrencyStrategy = ConcurrencyStrategy.CHAT,
|
|
52
|
+
flow_inactivity_timeout: timedelta | float = timedelta(minutes=5),
|
|
53
|
+
flow_cancellation_timeout: timedelta | float = timedelta(seconds=3),
|
|
54
|
+
shared_data: dict | None = None,
|
|
55
|
+
):
|
|
56
|
+
"""
|
|
57
|
+
Args:
|
|
58
|
+
bot: Bot instance (`ExtBot`) used to receive updates and send
|
|
59
|
+
requests to Telegram.
|
|
60
|
+
|
|
61
|
+
entry_coro: Coroutine launched when a new Flow is spawned. Receives the
|
|
62
|
+
`Context` of that Flow and runs as a separate task.
|
|
63
|
+
|
|
64
|
+
concurrency_limit:
|
|
65
|
+
The maximum number of `Flow` instances that can run concurrently.
|
|
66
|
+
If `None`, there is no limit. When the limit is reached, no new `Flow` is spawned,
|
|
67
|
+
and the corresponding `Update` is silently dropped. A warning is logged in this case.
|
|
68
|
+
|
|
69
|
+
spawn_trigger:
|
|
70
|
+
A trigger is a standard `Filter` from `telegram.ext.filters` or a custom filter implementation.
|
|
71
|
+
When an incoming `Update` matches the filter, a new `Flow` instance is spawned,
|
|
72
|
+
and the `Update` that triggered the spawn is available in the context through
|
|
73
|
+
the `ctx.initial_update` property.
|
|
74
|
+
For example, if `spawn_trigger = filters.Text(["Hello"]) | filters.PHOTO`,
|
|
75
|
+
a new `Flow` is spawned only when a user sends a message with the text `Hello` or sends a photo.
|
|
76
|
+
|
|
77
|
+
respawn_trigger:
|
|
78
|
+
The filter that determines whether an update belonging to an existing `Flow`
|
|
79
|
+
cancels that `Flow` and restarts it with the new update.
|
|
80
|
+
|
|
81
|
+
concurrency_strategy:
|
|
82
|
+
The concurrency strategy defines how incoming updates are processed concurrently.
|
|
83
|
+
For example, it determines whether a single `Flow` instance handles all incoming updates
|
|
84
|
+
or whether a separate `Flow` instance is spawned for each chat or each user within a chat.
|
|
85
|
+
Available strategies:
|
|
86
|
+
`ConcurrencyStrategy.CHAT`
|
|
87
|
+
`ConcurrencyStrategy.USER_IN_GLOBAL`
|
|
88
|
+
`ConcurrencyStrategy.USER_IN_CHAT`
|
|
89
|
+
`ConcurrencyStrategy.THREAD_IN_CHAT`
|
|
90
|
+
`ConcurrencyStrategy.SINGLE_FLOW`
|
|
91
|
+
For a description of each strategy, see the `ConcurrencyStrategy` class.
|
|
92
|
+
|
|
93
|
+
flow_inactivity_timeout:
|
|
94
|
+
The maximum period of inactivity allowed for a `Flow`. If this timeout expires,
|
|
95
|
+
the `Flow` is canceled.
|
|
96
|
+
|
|
97
|
+
A `Flow` is considered active whenever it receives a new `Update` through methods
|
|
98
|
+
such as `ctx.update()`, `ctx.message()`, and others.
|
|
99
|
+
|
|
100
|
+
The `flow_inactivity_timeout` parameter sets the default inactivity timeout for all `Flow` instances.
|
|
101
|
+
You can override this value for an individual `Flow` instance from within the `Flow`
|
|
102
|
+
using the context: `ctx.inactivity_timeout = timedelta(minutes=15)`.
|
|
103
|
+
|
|
104
|
+
When a `Flow` is canceled, its associated `asyncio.Task` is also canceled,
|
|
105
|
+
causing an `asyncio.CancelledError` exception to be raised inside the coroutine.
|
|
106
|
+
|
|
107
|
+
flow_cancellation_timeout:
|
|
108
|
+
The amount of time allowed to handle `asyncio.CancelledError` when a Flow is
|
|
109
|
+
canceled due to inactivity or respawning.
|
|
110
|
+
|
|
111
|
+
For example:
|
|
112
|
+
|
|
113
|
+
async def your_flow(ctx: Context):
|
|
114
|
+
try:
|
|
115
|
+
msg = await ctx.message()
|
|
116
|
+
except asyncio.CancelledError:
|
|
117
|
+
# If sleep exceeds flow_cancellation_timeout, another
|
|
118
|
+
# CancelledError will be raised in this block.
|
|
119
|
+
await asyncio.sleep(999)
|
|
120
|
+
|
|
121
|
+
shared_data:
|
|
122
|
+
Shared data passed to all Flow instances. This data is accessible through the Flow context.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
self._bot: ExtBot = bot
|
|
126
|
+
self._entry_coro = entry_coro
|
|
127
|
+
self._concurrency_strategy: BaseConcurrencyStrategy = concurrency_strategy
|
|
128
|
+
self._concurrency_limit: int | None = concurrency_limit
|
|
129
|
+
self._shared_data = shared_data or {}
|
|
130
|
+
self._respawn_trigger = respawn_trigger
|
|
131
|
+
self._last_update_id: int | None = None
|
|
132
|
+
self._poll_interval: int = 0
|
|
133
|
+
self._spawn_trigger: BaseFilter = spawn_trigger
|
|
134
|
+
self._allowed_updates: tuple[UpdateType, ...] | None = None
|
|
135
|
+
self._middlewares: list[Middleware | AsyncMiddleware] = []
|
|
136
|
+
self._lock: Lock = Lock()
|
|
137
|
+
self._is_running: bool = False
|
|
138
|
+
self._bg_tasks: set[Task] = set() # background tasks
|
|
139
|
+
self._startup_coroutines: list[tuple[str | None, Coroutine]] = []
|
|
140
|
+
|
|
141
|
+
self._flow_inactivity_timeout: float
|
|
142
|
+
if isinstance(flow_inactivity_timeout, timedelta):
|
|
143
|
+
self._flow_inactivity_timeout = float(flow_inactivity_timeout.total_seconds())
|
|
144
|
+
else:
|
|
145
|
+
self._flow_inactivity_timeout = float(flow_inactivity_timeout)
|
|
146
|
+
|
|
147
|
+
self._flow_cancellation_timeout: float
|
|
148
|
+
if isinstance(flow_cancellation_timeout, timedelta):
|
|
149
|
+
self._flow_cancellation_timeout = float(flow_cancellation_timeout.total_seconds())
|
|
150
|
+
else:
|
|
151
|
+
self._flow_cancellation_timeout = float(flow_cancellation_timeout)
|
|
152
|
+
|
|
153
|
+
self._spawned_flows: dict[FlowKey, Flow] = {}
|
|
154
|
+
|
|
155
|
+
async def _get_updates(self) -> tuple[Update, ...]:
|
|
156
|
+
# Skip updates on first launch
|
|
157
|
+
if self._last_update_id is None:
|
|
158
|
+
updates = await self._bot.get_updates(
|
|
159
|
+
offset=-1, timeout=0, allowed_updates=self._allowed_updates
|
|
160
|
+
)
|
|
161
|
+
self._last_update_id = updates[-1].update_id + 1 if len(updates) > 0 else 0
|
|
162
|
+
|
|
163
|
+
updates = await self._bot.get_updates(
|
|
164
|
+
offset=self._last_update_id,
|
|
165
|
+
timeout=self._poll_interval,
|
|
166
|
+
allowed_updates=self._allowed_updates,
|
|
167
|
+
)
|
|
168
|
+
if len(updates) > 0:
|
|
169
|
+
self._last_update_id = updates[-1].update_id + 1
|
|
170
|
+
|
|
171
|
+
return updates
|
|
172
|
+
|
|
173
|
+
def _spawn_new_flow(self, flow_key: FlowKey, initial_update: Update) -> None:
|
|
174
|
+
if flow_key in self._spawned_flows:
|
|
175
|
+
raise FlowAlreadySpawned("The flow with such an KEY has already been spawned")
|
|
176
|
+
|
|
177
|
+
if self._concurrency_limit is not None and len(self._spawned_flows) >= self._concurrency_limit:
|
|
178
|
+
_logger.warning("Concurrency limit reached. Unable to spawn a new flow.")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
context = Context(
|
|
182
|
+
bot=self._bot,
|
|
183
|
+
initial_update=initial_update,
|
|
184
|
+
inactivity_timeout=self._flow_inactivity_timeout,
|
|
185
|
+
shared_data=self._shared_data,
|
|
186
|
+
app=self,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
task = aio.create_task(
|
|
190
|
+
self._entry_coro(context),
|
|
191
|
+
name=f"flow_{flow_key.chat_id}_{flow_key.user_id}_{flow_key.thread_id}_{flow_key.update_id}",
|
|
192
|
+
)
|
|
193
|
+
task.add_done_callback(
|
|
194
|
+
lambda _task: self._done_flow_task_callback(_task, flow_key),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
new_flow = Flow(
|
|
198
|
+
task=task,
|
|
199
|
+
context=context,
|
|
200
|
+
key=flow_key,
|
|
201
|
+
)
|
|
202
|
+
self._spawned_flows[flow_key] = new_flow
|
|
203
|
+
|
|
204
|
+
_logger.debug(
|
|
205
|
+
f"Flow spawned (chat_id={flow_key.chat_id}, user_id={flow_key.user_id}, "
|
|
206
|
+
f"thread_id={flow_key.thread_id}, update_id={flow_key.update_id}), "
|
|
207
|
+
f"limit {len(self._spawned_flows)}/{self._concurrency_limit or '∞'}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
async def _cancel_inactive_flows(self):
|
|
211
|
+
"""
|
|
212
|
+
Cancel Flows that haven't received Updates for a long time
|
|
213
|
+
"""
|
|
214
|
+
inactive_keys: deque[FlowKey] = deque()
|
|
215
|
+
|
|
216
|
+
for flow in self._spawned_flows.values():
|
|
217
|
+
delta = time.monotonic() - flow.context.last_activity_at
|
|
218
|
+
if (
|
|
219
|
+
delta > flow.context.inactivity_timeout and get_task_status(flow.task) == TaskStatus.RUNNING
|
|
220
|
+
):
|
|
221
|
+
inactive_keys.append(flow.key)
|
|
222
|
+
|
|
223
|
+
await cancel_tasks(
|
|
224
|
+
[self._spawned_flows[key].task for key in inactive_keys], self._flow_cancellation_timeout
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
for key in inactive_keys:
|
|
228
|
+
if self._spawned_flows.get(key, None) is not None:
|
|
229
|
+
flow = self._spawned_flows[key]
|
|
230
|
+
status = get_task_status(flow.task)
|
|
231
|
+
if status == TaskStatus.EXCEPTION:
|
|
232
|
+
e = flow.task.exception()
|
|
233
|
+
_logger.error(e, exc_info=e)
|
|
234
|
+
if status == TaskStatus.RUNNING:
|
|
235
|
+
raise FlowCancellationFailed("Failed to cancel the task associated with the Flow.")
|
|
236
|
+
|
|
237
|
+
self._spawned_flows.pop(key, None)
|
|
238
|
+
|
|
239
|
+
async def _respawn_flow(self, key: FlowKey, initial_update: Update):
|
|
240
|
+
flow = self._spawned_flows[key]
|
|
241
|
+
|
|
242
|
+
if get_task_status(flow.task) == TaskStatus.RUNNING:
|
|
243
|
+
await cancel_tasks([flow.task], self._flow_cancellation_timeout)
|
|
244
|
+
|
|
245
|
+
if get_task_status(flow.task) != TaskStatus.RUNNING:
|
|
246
|
+
self._spawned_flows.pop(key, None)
|
|
247
|
+
self._spawn_new_flow(key, initial_update)
|
|
248
|
+
else:
|
|
249
|
+
raise UnexpectedError("Failed to cancel the task during flow respawn.")
|
|
250
|
+
|
|
251
|
+
def _done_flow_task_callback(self, task: Task, key: FlowKey):
|
|
252
|
+
flow = self._spawned_flows.get(key, None)
|
|
253
|
+
if flow is None:
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
status = get_task_status(flow.task)
|
|
257
|
+
if status == TaskStatus.EXCEPTION:
|
|
258
|
+
e = flow.task.exception()
|
|
259
|
+
_logger.error(e, exc_info=e)
|
|
260
|
+
elif status == TaskStatus.RUNNING:
|
|
261
|
+
raise UnexpectedError("The Flow task is still running")
|
|
262
|
+
|
|
263
|
+
self._spawned_flows.pop(key, None)
|
|
264
|
+
|
|
265
|
+
def _done_bg_task_callback(self, task: Task):
|
|
266
|
+
if get_task_status(task) == TaskStatus.EXCEPTION:
|
|
267
|
+
e = task.exception()
|
|
268
|
+
_logger.error(e, exc_info=e)
|
|
269
|
+
self._bg_tasks.discard(task)
|
|
270
|
+
|
|
271
|
+
def create_bg_task(self, coroutine: Coroutine, name: str | None = None):
|
|
272
|
+
"""Create a background asyncio task."""
|
|
273
|
+
task = aio.create_task(coroutine, name=name or f"background_task_{secrets.token_urlsafe(8)}")
|
|
274
|
+
task.add_done_callback(self._done_bg_task_callback)
|
|
275
|
+
self._bg_tasks.add(task)
|
|
276
|
+
|
|
277
|
+
async def process_update(self, update: Update):
|
|
278
|
+
async with self._lock:
|
|
279
|
+
await self._cancel_inactive_flows()
|
|
280
|
+
|
|
281
|
+
await aio.sleep(0)
|
|
282
|
+
|
|
283
|
+
for middleware in self._middlewares:
|
|
284
|
+
try:
|
|
285
|
+
if isinstance(middleware, Middleware):
|
|
286
|
+
update = middleware(update) # type: ignore
|
|
287
|
+
elif isinstance(middleware, AsyncMiddleware):
|
|
288
|
+
update = await middleware(update) # type: ignore
|
|
289
|
+
except Exception as e:
|
|
290
|
+
_logger.error(e, exc_info=e)
|
|
291
|
+
return
|
|
292
|
+
if update is None:
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
flow_key = self._concurrency_strategy.get_flow_key(update)
|
|
297
|
+
except FlowKeyError as e:
|
|
298
|
+
_logger.error(e, exc_info=e)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
if flow_key in self._spawned_flows:
|
|
302
|
+
if self._respawn_trigger is not None and self._respawn_trigger.check_update(update):
|
|
303
|
+
await self._respawn_flow(flow_key, update)
|
|
304
|
+
else:
|
|
305
|
+
await self._spawned_flows[flow_key].context.put_update(update)
|
|
306
|
+
else:
|
|
307
|
+
if self._spawn_trigger.check_update(update):
|
|
308
|
+
self._spawn_new_flow(flow_key, update)
|
|
309
|
+
|
|
310
|
+
await aio.sleep(0)
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _raise_system_exit():
|
|
314
|
+
raise SystemExit()
|
|
315
|
+
|
|
316
|
+
async def _long_polling_loop(self):
|
|
317
|
+
retrying = Retrying(
|
|
318
|
+
wait=wait_exponential(multiplier=1, min=1, max=64),
|
|
319
|
+
retry=retry_if_exception_type(telegram.error.TelegramError),
|
|
320
|
+
)
|
|
321
|
+
while True:
|
|
322
|
+
for attempt in retrying:
|
|
323
|
+
with attempt:
|
|
324
|
+
try:
|
|
325
|
+
updates = await self._get_updates()
|
|
326
|
+
except Exception as e:
|
|
327
|
+
_logger.error(f"Error in the long polling loop ({e.__class__.__name__}). {e}")
|
|
328
|
+
raise
|
|
329
|
+
|
|
330
|
+
if len(updates) > 0:
|
|
331
|
+
_logger.debug(f"Updates received: {len(updates)}")
|
|
332
|
+
for update in updates:
|
|
333
|
+
await self.process_update(update)
|
|
334
|
+
else:
|
|
335
|
+
await self._cancel_inactive_flows()
|
|
336
|
+
|
|
337
|
+
async def initialize(self):
|
|
338
|
+
await self._bot.initialize()
|
|
339
|
+
|
|
340
|
+
def add_startup_task(self, coroutine: Coroutine, name: str | None = None):
|
|
341
|
+
"""Coroutine that run as background task alongside long polling."""
|
|
342
|
+
self._startup_coroutines.append((name, coroutine))
|
|
343
|
+
|
|
344
|
+
async def shutdown(self, max_timeout: timedelta | float = 10.0):
|
|
345
|
+
"""Graceful shutdown.
|
|
346
|
+
Args:
|
|
347
|
+
max_timeout:
|
|
348
|
+
The maximum amount of time allowed for all Flow instances and background tasks to
|
|
349
|
+
handle asyncio.CancelledError and shutdown.
|
|
350
|
+
|
|
351
|
+
Example:
|
|
352
|
+
try:
|
|
353
|
+
...
|
|
354
|
+
await app.run_polling()
|
|
355
|
+
finally:
|
|
356
|
+
await app.shutdown(max_timeout=10.0)
|
|
357
|
+
"""
|
|
358
|
+
if isinstance(max_timeout, timedelta):
|
|
359
|
+
_max_timeout = float(max_timeout.total_seconds())
|
|
360
|
+
else:
|
|
361
|
+
_max_timeout = float(max_timeout)
|
|
362
|
+
|
|
363
|
+
tasks = [flow.task for flow in self._spawned_flows.values()]
|
|
364
|
+
flow_task_count = len(tasks)
|
|
365
|
+
bg_task_count = len(self._bg_tasks)
|
|
366
|
+
|
|
367
|
+
tasks.extend(self._bg_tasks)
|
|
368
|
+
if len(tasks) > 0:
|
|
369
|
+
_logger.info(
|
|
370
|
+
f"Waiting for tasks to be cancelled. Flow tasks: {flow_task_count}, background tasks: {bg_task_count}"
|
|
371
|
+
)
|
|
372
|
+
await cancel_tasks(tasks, _max_timeout)
|
|
373
|
+
|
|
374
|
+
await self._bot.shutdown()
|
|
375
|
+
self._is_running = False
|
|
376
|
+
_logger.info("Shutdown completed")
|
|
377
|
+
|
|
378
|
+
async def run_polling(
|
|
379
|
+
self,
|
|
380
|
+
poll_interval: int = 30,
|
|
381
|
+
allowed_updates: tuple[UpdateType, ...] = (
|
|
382
|
+
UpdateType.MESSAGE,
|
|
383
|
+
UpdateType.CALLBACK_QUERY,
|
|
384
|
+
),
|
|
385
|
+
handle_sys_signals: bool = True,
|
|
386
|
+
with_bg_tasks: Sequence[Coroutine] | None = None,
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Starts the long polling loop and processes incoming updates.
|
|
389
|
+
|
|
390
|
+
Fetches updates from Telegram via long polling and dispatches each update
|
|
391
|
+
to the matching Flow. Blocks until the loop is stopped (e.g. by a system
|
|
392
|
+
signal when ``handle_sys_signals`` is ``True``).
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
poll_interval: Timeout in seconds for long polling.
|
|
396
|
+
|
|
397
|
+
allowed_updates: Update types the bot subscribes to.
|
|
398
|
+
|
|
399
|
+
handle_sys_signals: If `True`, installs handlers for `SIGINT`,
|
|
400
|
+
`SIGTERM` and `SIGABRT` to stop polling on system signals. Has no
|
|
401
|
+
effect on Windows.
|
|
402
|
+
|
|
403
|
+
Example:
|
|
404
|
+
app = FlowApp(...)
|
|
405
|
+
try:
|
|
406
|
+
await app.run_polling(...)
|
|
407
|
+
finally:
|
|
408
|
+
await app.shutdown()
|
|
409
|
+
"""
|
|
410
|
+
if self._is_running:
|
|
411
|
+
raise PyteleflowError(f"{self.__class__.__name__} is already running.")
|
|
412
|
+
|
|
413
|
+
self._poll_interval = poll_interval
|
|
414
|
+
self._allowed_updates = allowed_updates
|
|
415
|
+
|
|
416
|
+
if handle_sys_signals and platform.system() != "Windows":
|
|
417
|
+
loop = aio.get_event_loop()
|
|
418
|
+
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGABRT):
|
|
419
|
+
loop.add_signal_handler(sig, self._raise_system_exit)
|
|
420
|
+
|
|
421
|
+
await self.initialize()
|
|
422
|
+
|
|
423
|
+
for name, coro in self._startup_coroutines:
|
|
424
|
+
self.create_bg_task(coro, name)
|
|
425
|
+
await aio.sleep(0)
|
|
426
|
+
|
|
427
|
+
_logger.info("Run long polling")
|
|
428
|
+
try:
|
|
429
|
+
self._is_running = True
|
|
430
|
+
await self._long_polling_loop()
|
|
431
|
+
except aio.CancelledError:
|
|
432
|
+
_logger.info("Stop long polling")
|
|
433
|
+
|
|
434
|
+
def add_pre_middlewares(self, middlewares: list[Middleware | AsyncMiddleware]):
|
|
435
|
+
self._middlewares.extend(middlewares)
|
|
436
|
+
|
|
437
|
+
@property
|
|
438
|
+
def spawned_flows(self) -> MappingProxyType[FlowKey, Flow]:
|
|
439
|
+
return MappingProxyType(self._spawned_flows)
|
pyteleflow/_flow.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from asyncio import Queue, Task
|
|
5
|
+
from collections.abc import Coroutine
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from re import Pattern
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from telegram import Bot, CallbackQuery, Message, Update
|
|
11
|
+
from telegram.ext import filters
|
|
12
|
+
from telegram.ext.filters import BaseFilter, MessageFilter
|
|
13
|
+
|
|
14
|
+
from . import filters_ext
|
|
15
|
+
from ._types import FlowKey
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from ._app import FlowApp
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Context:
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
bot: Bot,
|
|
25
|
+
initial_update: Update,
|
|
26
|
+
inactivity_timeout: float,
|
|
27
|
+
shared_data: dict,
|
|
28
|
+
app: FlowApp,
|
|
29
|
+
):
|
|
30
|
+
self._updates: Queue[Update] = Queue()
|
|
31
|
+
self.bot = bot
|
|
32
|
+
self._app = app
|
|
33
|
+
self._shared_data = shared_data
|
|
34
|
+
self._last_activity_at: float = time.monotonic()
|
|
35
|
+
|
|
36
|
+
self._inactivity_timeout: float = float(inactivity_timeout)
|
|
37
|
+
|
|
38
|
+
self._initial_update: Update = initial_update
|
|
39
|
+
self._last_update: Update = initial_update
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def inactivity_timeout(self) -> float:
|
|
43
|
+
return self._inactivity_timeout
|
|
44
|
+
|
|
45
|
+
@inactivity_timeout.setter
|
|
46
|
+
def inactivity_timeout(self, timeout: timedelta | float):
|
|
47
|
+
if isinstance(timeout, timedelta):
|
|
48
|
+
self._inactivity_timeout = float(timeout.total_seconds())
|
|
49
|
+
else:
|
|
50
|
+
self._inactivity_timeout = float(timeout)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def initial_update(self) -> Update:
|
|
54
|
+
"""The initial Update that triggered the Flow"""
|
|
55
|
+
return self._initial_update
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def shared_data(self) -> dict:
|
|
59
|
+
return self._shared_data
|
|
60
|
+
|
|
61
|
+
async def _get_update(self, _filter: BaseFilter | None = None) -> Update:
|
|
62
|
+
while True:
|
|
63
|
+
update = await self._updates.get()
|
|
64
|
+
if _filter is None or _filter.check_update(update):
|
|
65
|
+
self._last_update = update
|
|
66
|
+
return update
|
|
67
|
+
|
|
68
|
+
def create_bg_task(self, coroutine: Coroutine, name: str | None = None):
|
|
69
|
+
""" Create a background asyncio task. """
|
|
70
|
+
self._app.create_bg_task(coroutine, name)
|
|
71
|
+
|
|
72
|
+
async def update(self, _filter: BaseFilter | None = None) -> Update:
|
|
73
|
+
"""Waits for and returns the next Update from Flow context queue.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
_filter: Optional filter to match the Update against. If provided,
|
|
77
|
+
Updates that do not match the filter are ignored. The Update will wait until
|
|
78
|
+
a matching Update appears in the queue.
|
|
79
|
+
"""
|
|
80
|
+
return await self._get_update(_filter)
|
|
81
|
+
|
|
82
|
+
async def message(self, _filter: MessageFilter | None = None) -> Message:
|
|
83
|
+
while True:
|
|
84
|
+
if _filter:
|
|
85
|
+
update = await self._get_update(filters.UpdateType.MESSAGE & _filter)
|
|
86
|
+
else:
|
|
87
|
+
update = await self._get_update(filters.UpdateType.MESSAGE)
|
|
88
|
+
|
|
89
|
+
if update.message is not None:
|
|
90
|
+
return update.message
|
|
91
|
+
|
|
92
|
+
async def callback_query(self, data_pattern: str | Pattern | None = None) -> CallbackQuery:
|
|
93
|
+
while True:
|
|
94
|
+
update = await self._get_update(filters_ext.CallbackQuery(data_pattern))
|
|
95
|
+
if update.callback_query is not None:
|
|
96
|
+
return update.callback_query
|
|
97
|
+
|
|
98
|
+
async def put_update(self, update: Update):
|
|
99
|
+
"""Adds an Update to the Flow context queue."""
|
|
100
|
+
self._last_activity_at = time.monotonic()
|
|
101
|
+
await self._updates.put(update)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def last_activity_at(self) -> float:
|
|
105
|
+
return self._last_activity_at
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def last_update(self) -> Update:
|
|
109
|
+
"""Returns the last Update retrieved from the Flow context queue."""
|
|
110
|
+
return self._last_update
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Flow:
|
|
114
|
+
def __init__(self, task: Task, context: Context, key: FlowKey):
|
|
115
|
+
self.task = task
|
|
116
|
+
self.context = context
|
|
117
|
+
self.key = key
|
pyteleflow/_helpers.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import asyncio as aio
|
|
2
|
+
import logging
|
|
3
|
+
from asyncio import Task
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
|
|
6
|
+
from telegram import Update
|
|
7
|
+
from telegram.constants import UpdateType
|
|
8
|
+
|
|
9
|
+
from .errors import UnexpectedError
|
|
10
|
+
|
|
11
|
+
_logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TaskStatus(IntEnum):
|
|
15
|
+
RUNNING = 10
|
|
16
|
+
CANCELLED = 20
|
|
17
|
+
EXCEPTION = 30
|
|
18
|
+
FINISHED = 40
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_task_status(task: Task) -> TaskStatus:
|
|
22
|
+
if task.cancelled():
|
|
23
|
+
return TaskStatus.CANCELLED
|
|
24
|
+
elif task.done():
|
|
25
|
+
if task.exception() is not None:
|
|
26
|
+
return TaskStatus.EXCEPTION
|
|
27
|
+
else:
|
|
28
|
+
return TaskStatus.FINISHED
|
|
29
|
+
else:
|
|
30
|
+
return TaskStatus.RUNNING
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_update_type(update: Update) -> UpdateType:
|
|
34
|
+
for update_type in UpdateType:
|
|
35
|
+
if getattr(update, update_type.value) is not None:
|
|
36
|
+
return update_type
|
|
37
|
+
raise UnexpectedError("Fields that define the Update type are not set in the Update object.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def cancel_tasks(tasks: list[Task], cancellation_timeout: float):
|
|
41
|
+
if len(tasks) == 0:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
for task in tasks:
|
|
45
|
+
task.cancel()
|
|
46
|
+
|
|
47
|
+
_, pending = await aio.wait(tasks, timeout=cancellation_timeout, return_when=aio.ALL_COMPLETED)
|
|
48
|
+
|
|
49
|
+
if len(pending) > 0:
|
|
50
|
+
_logger.warning(f"{len(pending)} tasks did not complete within the cancellation timeout.")
|
|
51
|
+
|
|
52
|
+
# Retry stopping hung tasks
|
|
53
|
+
for task in pending:
|
|
54
|
+
task.cancel()
|
|
55
|
+
|
|
56
|
+
await aio.sleep(0)
|