aiogram-idle 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.
- aiogram_idle/__init__.py +146 -0
- aiogram_idle/activity.py +122 -0
- aiogram_idle/clock.py +69 -0
- aiogram_idle/compat.py +72 -0
- aiogram_idle/config.py +71 -0
- aiogram_idle/controls.py +167 -0
- aiogram_idle/coordinator.py +146 -0
- aiogram_idle/effect_scope.py +27 -0
- aiogram_idle/effects.py +160 -0
- aiogram_idle/errors.py +61 -0
- aiogram_idle/events.py +21 -0
- aiogram_idle/failures.py +67 -0
- aiogram_idle/integration.py +275 -0
- aiogram_idle/integrations/nav/__init__.py +20 -0
- aiogram_idle/integrations/nav/adapters.py +187 -0
- aiogram_idle/integrations/nav/codec.py +78 -0
- aiogram_idle/integrations/nav/effects.py +98 -0
- aiogram_idle/integrations/nav/engine.py +245 -0
- aiogram_idle/integrations/nav/errors.py +25 -0
- aiogram_idle/integrations/nav/installation.py +295 -0
- aiogram_idle/integrations/nav/integration.py +245 -0
- aiogram_idle/integrations/nav/lifecycle.py +143 -0
- aiogram_idle/integrations/nav/mapping.py +106 -0
- aiogram_idle/integrations/nav/overlay.py +71 -0
- aiogram_idle/integrations/nav/policies.py +73 -0
- aiogram_idle/integrations/nav/presentation.py +105 -0
- aiogram_idle/integrations/nav/receipts.py +49 -0
- aiogram_idle/integrations/nav/recovery.py +119 -0
- aiogram_idle/integrations/nav/repository.py +375 -0
- aiogram_idle/integrations/nav/staging.py +195 -0
- aiogram_idle/machine.py +66 -0
- aiogram_idle/models.py +383 -0
- aiogram_idle/policies.py +87 -0
- aiogram_idle/presentation.py +299 -0
- aiogram_idle/protocols.py +68 -0
- aiogram_idle/py.typed +0 -0
- aiogram_idle/questions.py +184 -0
- aiogram_idle/receipts.py +129 -0
- aiogram_idle/runtime.py +385 -0
- aiogram_idle/storage/__init__.py +7 -0
- aiogram_idle/storage/base.py +118 -0
- aiogram_idle/storage/codec.py +292 -0
- aiogram_idle/storage/component_transaction.py +244 -0
- aiogram_idle/storage/control_tokens.py +61 -0
- aiogram_idle/storage/coordination.py +21 -0
- aiogram_idle/storage/memory.py +499 -0
- aiogram_idle/storage/queue.py +114 -0
- aiogram_idle/storage/receipts.py +99 -0
- aiogram_idle/storage/redis.py +352 -0
- aiogram_idle/storage/redis_admin.py +78 -0
- aiogram_idle/storage/redis_coordinator.py +311 -0
- aiogram_idle/storage/redis_engine.py +239 -0
- aiogram_idle/storage/redis_jobs.py +226 -0
- aiogram_idle/storage/redis_quarantine.py +142 -0
- aiogram_idle/storage/redis_scripts.py +162 -0
- aiogram_idle/storage/redis_store.py +464 -0
- aiogram_idle/storage/redis_transport.py +121 -0
- aiogram_idle/storage/retention.py +47 -0
- aiogram_idle/storage/state.py +51 -0
- aiogram_idle/worker.py +442 -0
- aiogram_idle-0.1.0.dist-info/METADATA +123 -0
- aiogram_idle-0.1.0.dist-info/RECORD +64 -0
- aiogram_idle-0.1.0.dist-info/WHEEL +4 -0
- aiogram_idle-0.1.0.dist-info/licenses/LICENSE +21 -0
aiogram_idle/__init__.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Standalone inactivity policies for aiogram 3; no navigation or Redis dependency."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from .activity import IdleContext
|
|
6
|
+
from .clock import Clock, ManualClock, SystemClock
|
|
7
|
+
from .config import AmbiguousSendPolicy, IdleConfig
|
|
8
|
+
from .controls import (
|
|
9
|
+
CommandPromptControls,
|
|
10
|
+
ControlContext,
|
|
11
|
+
ControlResult,
|
|
12
|
+
InlinePromptControls,
|
|
13
|
+
ReplyPromptControls,
|
|
14
|
+
)
|
|
15
|
+
from .coordinator import AiogramMemoryCoordinator, MemoryCoordinator
|
|
16
|
+
from .errors import (
|
|
17
|
+
ActivityConflict,
|
|
18
|
+
BackendInconsistent,
|
|
19
|
+
CommitUnknown,
|
|
20
|
+
ConfigurationError,
|
|
21
|
+
ConfigurationMismatch,
|
|
22
|
+
EffectUnknown,
|
|
23
|
+
IdleError,
|
|
24
|
+
OperationIdConflict,
|
|
25
|
+
Overloaded,
|
|
26
|
+
OwnershipLost,
|
|
27
|
+
PersistenceUnavailable,
|
|
28
|
+
SchemaUnsupported,
|
|
29
|
+
SessionExpired,
|
|
30
|
+
UnsupportedContext,
|
|
31
|
+
VersionConflict,
|
|
32
|
+
)
|
|
33
|
+
from .models import (
|
|
34
|
+
ActionReceipt,
|
|
35
|
+
ActivityGuard,
|
|
36
|
+
ActivitySnapshot,
|
|
37
|
+
ActivitySpec,
|
|
38
|
+
ConfigManifest,
|
|
39
|
+
ConfigUpdate,
|
|
40
|
+
FlowContext,
|
|
41
|
+
FlowEligibility,
|
|
42
|
+
IdleConversationKey,
|
|
43
|
+
IdleEvent,
|
|
44
|
+
IdleSnapshot,
|
|
45
|
+
IdleStatus,
|
|
46
|
+
MessageRef,
|
|
47
|
+
PendingOperation,
|
|
48
|
+
Phase,
|
|
49
|
+
PollReport,
|
|
50
|
+
ReconcileReport,
|
|
51
|
+
RuntimeSnapshot,
|
|
52
|
+
SessionGuard,
|
|
53
|
+
SessionSnapshot,
|
|
54
|
+
)
|
|
55
|
+
from .policies import ExemptPolicy, IdlePolicies, PromptPolicy, QuietRestorePolicy
|
|
56
|
+
from .presentation import (
|
|
57
|
+
DeliveryReceipt,
|
|
58
|
+
FinishRequest,
|
|
59
|
+
PromptReceipt,
|
|
60
|
+
PromptRequest,
|
|
61
|
+
PromptTexts,
|
|
62
|
+
QuietHours,
|
|
63
|
+
QuietHoursResolver,
|
|
64
|
+
TelegramPromptPresenter,
|
|
65
|
+
)
|
|
66
|
+
from .protocols import Coordinator, EffectRecorder, FlowAdapter, PromptPresenter, SessionAuthority
|
|
67
|
+
from .runtime import IdleRuntime
|
|
68
|
+
from .storage.base import IdleBackend
|
|
69
|
+
from .storage.memory import MemoryIdleBackend
|
|
70
|
+
from .storage.state import OperationLookup
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
__version__ = version("aiogram-idle")
|
|
74
|
+
except PackageNotFoundError: # Allow direct source imports before installation.
|
|
75
|
+
__version__ = "0+unknown"
|
|
76
|
+
__all__ = [
|
|
77
|
+
"ActionReceipt",
|
|
78
|
+
"ActivityConflict",
|
|
79
|
+
"ActivityGuard",
|
|
80
|
+
"ActivitySnapshot",
|
|
81
|
+
"ActivitySpec",
|
|
82
|
+
"AiogramMemoryCoordinator",
|
|
83
|
+
"AmbiguousSendPolicy",
|
|
84
|
+
"BackendInconsistent",
|
|
85
|
+
"Clock",
|
|
86
|
+
"CommandPromptControls",
|
|
87
|
+
"CommitUnknown",
|
|
88
|
+
"ConfigManifest",
|
|
89
|
+
"ConfigUpdate",
|
|
90
|
+
"ConfigurationError",
|
|
91
|
+
"ConfigurationMismatch",
|
|
92
|
+
"ControlContext",
|
|
93
|
+
"ControlResult",
|
|
94
|
+
"Coordinator",
|
|
95
|
+
"DeliveryReceipt",
|
|
96
|
+
"EffectRecorder",
|
|
97
|
+
"EffectUnknown",
|
|
98
|
+
"ExemptPolicy",
|
|
99
|
+
"FinishRequest",
|
|
100
|
+
"FlowAdapter",
|
|
101
|
+
"FlowContext",
|
|
102
|
+
"FlowEligibility",
|
|
103
|
+
"IdleBackend",
|
|
104
|
+
"IdleConfig",
|
|
105
|
+
"IdleContext",
|
|
106
|
+
"IdleConversationKey",
|
|
107
|
+
"IdleError",
|
|
108
|
+
"IdleEvent",
|
|
109
|
+
"IdlePolicies",
|
|
110
|
+
"IdleRuntime",
|
|
111
|
+
"IdleSnapshot",
|
|
112
|
+
"IdleStatus",
|
|
113
|
+
"InlinePromptControls",
|
|
114
|
+
"ManualClock",
|
|
115
|
+
"MemoryCoordinator",
|
|
116
|
+
"MemoryIdleBackend",
|
|
117
|
+
"MessageRef",
|
|
118
|
+
"OperationIdConflict",
|
|
119
|
+
"OperationLookup",
|
|
120
|
+
"Overloaded",
|
|
121
|
+
"OwnershipLost",
|
|
122
|
+
"PendingOperation",
|
|
123
|
+
"PersistenceUnavailable",
|
|
124
|
+
"Phase",
|
|
125
|
+
"PollReport",
|
|
126
|
+
"PromptPolicy",
|
|
127
|
+
"PromptPresenter",
|
|
128
|
+
"PromptReceipt",
|
|
129
|
+
"PromptRequest",
|
|
130
|
+
"PromptTexts",
|
|
131
|
+
"QuietHours",
|
|
132
|
+
"QuietHoursResolver",
|
|
133
|
+
"QuietRestorePolicy",
|
|
134
|
+
"ReconcileReport",
|
|
135
|
+
"ReplyPromptControls",
|
|
136
|
+
"RuntimeSnapshot",
|
|
137
|
+
"SchemaUnsupported",
|
|
138
|
+
"SessionAuthority",
|
|
139
|
+
"SessionExpired",
|
|
140
|
+
"SessionGuard",
|
|
141
|
+
"SessionSnapshot",
|
|
142
|
+
"SystemClock",
|
|
143
|
+
"TelegramPromptPresenter",
|
|
144
|
+
"UnsupportedContext",
|
|
145
|
+
"VersionConflict",
|
|
146
|
+
]
|
aiogram_idle/activity.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Guarded activity entry points; never infer acceptance from an incoming update."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .models import (
|
|
11
|
+
ActivityGuard,
|
|
12
|
+
ActivitySnapshot,
|
|
13
|
+
IdleConversationKey,
|
|
14
|
+
SessionGuard,
|
|
15
|
+
SessionSnapshot,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .runtime import IdleRuntime
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IdleContext:
|
|
25
|
+
def __init__(self, runtime: IdleRuntime, key: IdleConversationKey) -> None:
|
|
26
|
+
self.runtime = runtime
|
|
27
|
+
self.key = key
|
|
28
|
+
|
|
29
|
+
async def open_session(self, *, operation_id: str, lifetime_seconds: float) -> SessionSnapshot:
|
|
30
|
+
async with self.runtime.coordinator.hold(self.key):
|
|
31
|
+
await self.runtime.initialize()
|
|
32
|
+
return await self.runtime.backend.open_session(
|
|
33
|
+
self.key, lifetime_seconds=lifetime_seconds, operation_id=operation_id
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
async def replace_session(
|
|
37
|
+
self, *, expected: SessionGuard, operation_id: str, lifetime_seconds: float
|
|
38
|
+
) -> SessionSnapshot:
|
|
39
|
+
async with self.runtime.coordinator.hold(self.key):
|
|
40
|
+
await self.runtime.initialize()
|
|
41
|
+
return await self.runtime.backend.replace_session(
|
|
42
|
+
self.key,
|
|
43
|
+
expected=expected,
|
|
44
|
+
lifetime_seconds=lifetime_seconds,
|
|
45
|
+
operation_id=operation_id,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
async def current(self) -> ActivitySnapshot | None:
|
|
49
|
+
async with self.runtime.coordinator.hold(self.key):
|
|
50
|
+
snapshot = await self.runtime.backend.load(self.key)
|
|
51
|
+
if snapshot is None or snapshot.session.expires_at <= self.runtime.clock.now():
|
|
52
|
+
return None
|
|
53
|
+
if (
|
|
54
|
+
snapshot.activity is not None
|
|
55
|
+
and snapshot.activity.expires_at <= self.runtime.clock.now()
|
|
56
|
+
):
|
|
57
|
+
return None
|
|
58
|
+
return snapshot.activity
|
|
59
|
+
|
|
60
|
+
async def enter(
|
|
61
|
+
self,
|
|
62
|
+
activity_key: str,
|
|
63
|
+
*,
|
|
64
|
+
policy_id: str,
|
|
65
|
+
adapter_key: str,
|
|
66
|
+
params: Mapping[str, object],
|
|
67
|
+
session_id: str,
|
|
68
|
+
expires_at: datetime | float,
|
|
69
|
+
operation_id: str,
|
|
70
|
+
expected: ActivityGuard | None = None,
|
|
71
|
+
) -> ActivitySnapshot:
|
|
72
|
+
async with self.runtime.coordinator.hold(self.key):
|
|
73
|
+
await self.runtime.initialize()
|
|
74
|
+
self.runtime.adapter(adapter_key)
|
|
75
|
+
manifest = await self.runtime.manifest()
|
|
76
|
+
previous = await self.runtime.current_prompt(self.key)
|
|
77
|
+
result = await self.runtime.backend.enter(
|
|
78
|
+
self.key,
|
|
79
|
+
activity_key=activity_key,
|
|
80
|
+
policy_id=policy_id,
|
|
81
|
+
policy=self.runtime.policies[policy_id],
|
|
82
|
+
adapter_key=adapter_key,
|
|
83
|
+
params=params,
|
|
84
|
+
session_id=session_id,
|
|
85
|
+
expires_at=expires_at,
|
|
86
|
+
operation_id=operation_id,
|
|
87
|
+
expected=expected,
|
|
88
|
+
enabled=manifest.enabled,
|
|
89
|
+
expected_manifest=manifest.generation,
|
|
90
|
+
)
|
|
91
|
+
if previous is not None:
|
|
92
|
+
try:
|
|
93
|
+
await self.runtime.presenter.strip(previous)
|
|
94
|
+
except Exception:
|
|
95
|
+
logger.warning("Superseded prompt cleanup unavailable")
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
async def touch(self, *, expected: ActivityGuard, operation_id: str) -> ActivitySnapshot:
|
|
99
|
+
async with self.runtime.coordinator.hold(self.key):
|
|
100
|
+
await self.runtime.initialize()
|
|
101
|
+
manifest = await self.runtime.manifest()
|
|
102
|
+
previous = await self.runtime.current_prompt(self.key)
|
|
103
|
+
result = await self.runtime.backend.touch(
|
|
104
|
+
self.key,
|
|
105
|
+
expected=expected,
|
|
106
|
+
operation_id=operation_id,
|
|
107
|
+
enabled=manifest.enabled,
|
|
108
|
+
expected_manifest=manifest.generation,
|
|
109
|
+
)
|
|
110
|
+
if previous is not None:
|
|
111
|
+
try:
|
|
112
|
+
await self.runtime.presenter.strip(previous)
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.warning("Superseded prompt cleanup unavailable")
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
async def clear(self, *, expected: ActivityGuard, reason: str, operation_id: str) -> None:
|
|
118
|
+
async with self.runtime.coordinator.hold(self.key):
|
|
119
|
+
await self.runtime.initialize()
|
|
120
|
+
await self.runtime.backend.clear(
|
|
121
|
+
self.key, expected=expected, operation_id=operation_id, reason=reason
|
|
122
|
+
)
|
aiogram_idle/clock.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Injectable wall and monotonic clocks for live runtimes and deterministic tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import time
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Clock(Protocol):
|
|
12
|
+
def now(self) -> float: ...
|
|
13
|
+
def monotonic(self) -> float: ...
|
|
14
|
+
async def sleep(self, seconds: float) -> None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SystemClock:
|
|
18
|
+
def now(self) -> float:
|
|
19
|
+
return time.time()
|
|
20
|
+
|
|
21
|
+
def monotonic(self) -> float:
|
|
22
|
+
return time.monotonic()
|
|
23
|
+
|
|
24
|
+
async def sleep(self, seconds: float) -> None:
|
|
25
|
+
await asyncio.sleep(seconds)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ManualClock:
|
|
29
|
+
"""Advance explicitly; sleeping tasks wake without real-time delays."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, now: float = 1_700_000_000.0) -> None:
|
|
32
|
+
if not math.isfinite(now):
|
|
33
|
+
raise ValueError("clock time must be finite")
|
|
34
|
+
self._now = now
|
|
35
|
+
self._monotonic = 0.0
|
|
36
|
+
self._waiters: list[tuple[float, asyncio.Future[None]]] = []
|
|
37
|
+
|
|
38
|
+
def now(self) -> float:
|
|
39
|
+
return self._now
|
|
40
|
+
|
|
41
|
+
def monotonic(self) -> float:
|
|
42
|
+
return self._monotonic
|
|
43
|
+
|
|
44
|
+
def advance(self, seconds: float) -> None:
|
|
45
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
46
|
+
raise ValueError("clock advance must be finite and nonnegative")
|
|
47
|
+
self._now += seconds
|
|
48
|
+
self._monotonic += seconds
|
|
49
|
+
remaining: list[tuple[float, asyncio.Future[None]]] = []
|
|
50
|
+
for deadline, future in self._waiters:
|
|
51
|
+
if deadline <= self._monotonic:
|
|
52
|
+
if not future.done():
|
|
53
|
+
future.set_result(None)
|
|
54
|
+
elif not future.done():
|
|
55
|
+
remaining.append((deadline, future))
|
|
56
|
+
self._waiters = remaining
|
|
57
|
+
|
|
58
|
+
async def sleep(self, seconds: float) -> None:
|
|
59
|
+
if seconds <= 0:
|
|
60
|
+
await asyncio.sleep(0)
|
|
61
|
+
return
|
|
62
|
+
future = asyncio.get_running_loop().create_future()
|
|
63
|
+
item = (self._monotonic + seconds, future)
|
|
64
|
+
self._waiters.append(item)
|
|
65
|
+
try:
|
|
66
|
+
await future
|
|
67
|
+
finally:
|
|
68
|
+
if item in self._waiters:
|
|
69
|
+
self._waiters.remove(item)
|
aiogram_idle/compat.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Task-preserving timeout scope, including Python 3.10."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import AsyncGenerator, Callable
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@asynccontextmanager
|
|
12
|
+
async def bounded(seconds: float) -> AsyncGenerator[None, None]:
|
|
13
|
+
task = asyncio.current_task()
|
|
14
|
+
if task is None:
|
|
15
|
+
raise RuntimeError("Timeout scope requires an asyncio task")
|
|
16
|
+
cancelling = cast(Callable[[], int] | None, getattr(task, "cancelling", None))
|
|
17
|
+
uncancel = cast(Callable[[], int] | None, getattr(task, "uncancel", None))
|
|
18
|
+
baseline = cancelling() if cancelling is not None else 0
|
|
19
|
+
expired = False
|
|
20
|
+
consumed = False
|
|
21
|
+
marker = object()
|
|
22
|
+
legacy_marker = False
|
|
23
|
+
legacy_previous: object = None
|
|
24
|
+
|
|
25
|
+
def expire() -> None:
|
|
26
|
+
nonlocal expired, legacy_marker, legacy_previous
|
|
27
|
+
expired = True
|
|
28
|
+
if cancelling is None:
|
|
29
|
+
legacy_previous = getattr(task, "_cancel_message", None)
|
|
30
|
+
task.cancel(marker)
|
|
31
|
+
if cancelling is None and hasattr(task, "_cancel_message"):
|
|
32
|
+
# CPython 3.10 has no cancellation counter. Pin its message so even
|
|
33
|
+
# an external cancel(None) racing this cancellation remains distinct.
|
|
34
|
+
# Other Task implementations fall back to the delivered marker.
|
|
35
|
+
try:
|
|
36
|
+
setattr(task, "_cancel_message", marker) # noqa: B010 -- guarded 3.10-only attribute
|
|
37
|
+
except (AttributeError, TypeError):
|
|
38
|
+
pass
|
|
39
|
+
else:
|
|
40
|
+
legacy_marker = True
|
|
41
|
+
|
|
42
|
+
def consume() -> int:
|
|
43
|
+
nonlocal consumed
|
|
44
|
+
if uncancel is not None and not consumed:
|
|
45
|
+
consumed = True
|
|
46
|
+
return uncancel()
|
|
47
|
+
return cancelling() if cancelling is not None else 0
|
|
48
|
+
|
|
49
|
+
handle = asyncio.get_running_loop().call_later(seconds, expire)
|
|
50
|
+
try:
|
|
51
|
+
yield
|
|
52
|
+
except asyncio.CancelledError as exc:
|
|
53
|
+
if expired:
|
|
54
|
+
remaining = consume()
|
|
55
|
+
owned = (
|
|
56
|
+
remaining <= baseline
|
|
57
|
+
if cancelling is not None
|
|
58
|
+
else exc.args == (marker,)
|
|
59
|
+
and (not legacy_marker or getattr(task, "_cancel_message", None) is marker)
|
|
60
|
+
)
|
|
61
|
+
if owned:
|
|
62
|
+
raise asyncio.TimeoutError from exc
|
|
63
|
+
raise
|
|
64
|
+
finally:
|
|
65
|
+
handle.cancel()
|
|
66
|
+
if expired:
|
|
67
|
+
if legacy_marker and getattr(task, "_cancel_message", None) is marker:
|
|
68
|
+
# Restore an outer timeout marker, never an external cancellation.
|
|
69
|
+
setattr(task, "_cancel_message", legacy_previous) # noqa: B010
|
|
70
|
+
# A body that suppresses CancelledError still must relinquish exactly
|
|
71
|
+
# this timeout's request; external cancellation requests are retained.
|
|
72
|
+
consume()
|
aiogram_idle/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Validated library settings; importing the package never reads the environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass, fields
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from .errors import ConfigurationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AmbiguousSendPolicy(str, Enum):
|
|
14
|
+
SUPPRESS = "suppress"
|
|
15
|
+
RETRY = "retry"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class IdleConfig:
|
|
20
|
+
enabled: bool = False
|
|
21
|
+
poll_seconds: float = 10
|
|
22
|
+
concurrency: int = 8
|
|
23
|
+
claim_limit: int = 8
|
|
24
|
+
lease_seconds: float = 60
|
|
25
|
+
renew_seconds: float = 15
|
|
26
|
+
operation_timeout_seconds: float = 45
|
|
27
|
+
lock_wait_seconds: float = 5
|
|
28
|
+
shutdown_seconds: float = 10
|
|
29
|
+
retry_max_attempts: int = 5
|
|
30
|
+
retry_max_age_seconds: float = 300
|
|
31
|
+
recovery_retention_seconds: float = 3600
|
|
32
|
+
ambiguous_send: AmbiguousSendPolicy = AmbiguousSendPolicy.SUPPRESS
|
|
33
|
+
max_conversations: int = 10_000
|
|
34
|
+
max_receipts_per_conversation: int = 256
|
|
35
|
+
max_tokens_per_conversation: int = 16
|
|
36
|
+
max_params_bytes: int = 16_384
|
|
37
|
+
max_components_bytes: int = 8_388_608
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if not isinstance(cast(object, self.enabled), bool):
|
|
41
|
+
raise ConfigurationError("enabled must be a boolean")
|
|
42
|
+
integer_fields = {
|
|
43
|
+
"concurrency",
|
|
44
|
+
"claim_limit",
|
|
45
|
+
"retry_max_attempts",
|
|
46
|
+
"max_conversations",
|
|
47
|
+
"max_receipts_per_conversation",
|
|
48
|
+
"max_tokens_per_conversation",
|
|
49
|
+
"max_params_bytes",
|
|
50
|
+
"max_components_bytes",
|
|
51
|
+
}
|
|
52
|
+
for field in fields(self):
|
|
53
|
+
value = getattr(self, field.name)
|
|
54
|
+
if field.name in {"enabled", "ambiguous_send"}:
|
|
55
|
+
continue
|
|
56
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
|
57
|
+
raise ConfigurationError(f"{field.name} must be numeric")
|
|
58
|
+
if not math.isfinite(value) or value <= 0:
|
|
59
|
+
raise ConfigurationError(f"{field.name} must be positive and finite")
|
|
60
|
+
if field.name in integer_fields and not isinstance(value, int):
|
|
61
|
+
raise ConfigurationError(f"{field.name} must be an integer")
|
|
62
|
+
if self.max_components_bytes > 8_388_608:
|
|
63
|
+
raise ConfigurationError("max_components_bytes exceeds the supported 8 MiB bound")
|
|
64
|
+
if self.claim_limit > self.concurrency:
|
|
65
|
+
raise ConfigurationError("claim_limit exceeds worker concurrency")
|
|
66
|
+
if self.renew_seconds * 2 >= self.lease_seconds:
|
|
67
|
+
raise ConfigurationError("renew interval must be below half the lease")
|
|
68
|
+
if self.lock_wait_seconds >= self.lease_seconds:
|
|
69
|
+
raise ConfigurationError("lock wait must be shorter than lease")
|
|
70
|
+
if not isinstance(cast(object, self.ambiguous_send), AmbiguousSendPolicy):
|
|
71
|
+
raise ConfigurationError("unknown ambiguous-send policy")
|
aiogram_idle/controls.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""All Telegram control styles enter the same authorized runtime pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import ClassVar, Literal, Protocol
|
|
7
|
+
|
|
8
|
+
from aiogram import Bot
|
|
9
|
+
from aiogram.exceptions import TelegramAPIError
|
|
10
|
+
from aiogram.filters import Command
|
|
11
|
+
from aiogram.types import CallbackQuery, Message
|
|
12
|
+
|
|
13
|
+
from .models import IdleConversationKey
|
|
14
|
+
from .presentation import PromptReceipt, PromptTexts
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ControlContext:
|
|
19
|
+
key: IdleConversationKey
|
|
20
|
+
actor_id: int
|
|
21
|
+
transport: str = "commands"
|
|
22
|
+
prompt_id: str | None = None
|
|
23
|
+
token: str | None = None
|
|
24
|
+
message_id: int | None = None
|
|
25
|
+
operation_id: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ControlResult:
|
|
30
|
+
accepted: bool
|
|
31
|
+
outcome: str
|
|
32
|
+
message: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ReplyPromptControls:
|
|
37
|
+
mode: ClassVar[str] = "reply"
|
|
38
|
+
continue_label: str = "Continue"
|
|
39
|
+
cancel_label: str = "Cancel"
|
|
40
|
+
|
|
41
|
+
def match(self, text: str) -> Literal["continue", "cancel"] | None:
|
|
42
|
+
if self.continue_label == self.cancel_label:
|
|
43
|
+
raise ValueError("Reply prompt labels must differ")
|
|
44
|
+
if text == self.continue_label:
|
|
45
|
+
return "continue"
|
|
46
|
+
if text == self.cancel_label:
|
|
47
|
+
return "cancel"
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class InlinePromptControls:
|
|
53
|
+
mode: ClassVar[str] = "inline"
|
|
54
|
+
|
|
55
|
+
def match(self, data: str) -> tuple[Literal["continue", "cancel"], str] | None:
|
|
56
|
+
parts = data.split(":", 2)
|
|
57
|
+
if len(parts) != 3 or parts[0] != "idle" or not parts[2]:
|
|
58
|
+
return None
|
|
59
|
+
if parts[1] == "c":
|
|
60
|
+
return "continue", parts[2]
|
|
61
|
+
if parts[1] == "x":
|
|
62
|
+
return "cancel", parts[2]
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class CommandPromptControls:
|
|
68
|
+
mode: ClassVar[str] = "commands"
|
|
69
|
+
|
|
70
|
+
async def match(self, message: Message, bot: Bot) -> Literal["continue", "cancel"] | None:
|
|
71
|
+
if await Command("continue")(message, bot):
|
|
72
|
+
return "continue"
|
|
73
|
+
if await Command("cancel")(message, bot):
|
|
74
|
+
return "cancel"
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ControlRuntime(Protocol):
|
|
79
|
+
async def current_prompt(self, key: IdleConversationKey) -> PromptReceipt | None: ...
|
|
80
|
+
async def continue_current(self, context: ControlContext) -> ControlResult: ...
|
|
81
|
+
async def cancel_current(self, context: ControlContext) -> ControlResult: ...
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def handle_control(
|
|
85
|
+
runtime: ControlRuntime,
|
|
86
|
+
event: Message | CallbackQuery,
|
|
87
|
+
key: IdleConversationKey,
|
|
88
|
+
*,
|
|
89
|
+
bot: Bot,
|
|
90
|
+
texts: PromptTexts,
|
|
91
|
+
update_id: int,
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""Return True only when this update belongs to idle controls.
|
|
94
|
+
|
|
95
|
+
A bare /cancel without a prompt falls through to the application's cancel
|
|
96
|
+
handler. Ordinary text is reserved only while the active reply tray asks it.
|
|
97
|
+
"""
|
|
98
|
+
prompt = await runtime.current_prompt(key)
|
|
99
|
+
if isinstance(event, CallbackQuery):
|
|
100
|
+
data = event.data or ""
|
|
101
|
+
if not data.startswith("idle:"):
|
|
102
|
+
return False
|
|
103
|
+
parsed = InlinePromptControls().match(data)
|
|
104
|
+
if parsed is None:
|
|
105
|
+
await _ack(bot, event.id, texts.stale)
|
|
106
|
+
return True
|
|
107
|
+
action, token = parsed
|
|
108
|
+
context = ControlContext(
|
|
109
|
+
key=key,
|
|
110
|
+
actor_id=event.from_user.id,
|
|
111
|
+
transport="inline",
|
|
112
|
+
prompt_id=prompt.prompt_id if prompt else None,
|
|
113
|
+
token=token,
|
|
114
|
+
message_id=event.message.message_id if event.message else None,
|
|
115
|
+
operation_id=f"update:{update_id}:idle:{action}",
|
|
116
|
+
)
|
|
117
|
+
# Stop Telegram's spinner before a slow application adapter executes.
|
|
118
|
+
known = prompt is not None and token in {prompt.continue_token, prompt.cancel_token}
|
|
119
|
+
await _ack(bot, event.id, "" if known else texts.stale)
|
|
120
|
+
await _dispatch(runtime, action, context)
|
|
121
|
+
# A rejected token proves neither ownership nor obsolescence of its
|
|
122
|
+
# message. Preserve untracked or partially rendered component keyboards.
|
|
123
|
+
return True
|
|
124
|
+
action = await CommandPromptControls().match(event, bot)
|
|
125
|
+
if action == "cancel" and prompt is None:
|
|
126
|
+
return False
|
|
127
|
+
if action == "continue" and prompt is None:
|
|
128
|
+
await bot.send_message(event.chat.id, texts.stale, parse_mode=None)
|
|
129
|
+
return True
|
|
130
|
+
if action is None and prompt is not None and prompt.controls in {"reply", "hybrid"}:
|
|
131
|
+
action = ReplyPromptControls(texts.continue_label, texts.cancel_label).match(
|
|
132
|
+
event.text or ""
|
|
133
|
+
)
|
|
134
|
+
transport = "reply"
|
|
135
|
+
else:
|
|
136
|
+
transport = "commands"
|
|
137
|
+
if action is None:
|
|
138
|
+
return False
|
|
139
|
+
context = ControlContext(
|
|
140
|
+
key=key,
|
|
141
|
+
actor_id=event.from_user.id if event.from_user else 0,
|
|
142
|
+
transport=transport,
|
|
143
|
+
prompt_id=prompt.prompt_id if prompt else None,
|
|
144
|
+
message_id=event.message_id,
|
|
145
|
+
operation_id=f"update:{update_id}:idle:{action}",
|
|
146
|
+
)
|
|
147
|
+
result = await _dispatch(runtime, action, context)
|
|
148
|
+
if not result.accepted:
|
|
149
|
+
await bot.send_message(event.chat.id, result.message or texts.stale, parse_mode=None)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def _dispatch(
|
|
154
|
+
runtime: ControlRuntime,
|
|
155
|
+
action: str,
|
|
156
|
+
context: ControlContext,
|
|
157
|
+
) -> ControlResult:
|
|
158
|
+
if action == "continue":
|
|
159
|
+
return await runtime.continue_current(context)
|
|
160
|
+
return await runtime.cancel_current(context)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def _ack(bot: Bot, callback_id: str, text: str) -> None:
|
|
164
|
+
try:
|
|
165
|
+
await bot.answer_callback_query(callback_id, text=text)
|
|
166
|
+
except TelegramAPIError:
|
|
167
|
+
pass # Expired acknowledgement cannot undo an already committed answer.
|