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.
Files changed (64) hide show
  1. aiogram_idle/__init__.py +146 -0
  2. aiogram_idle/activity.py +122 -0
  3. aiogram_idle/clock.py +69 -0
  4. aiogram_idle/compat.py +72 -0
  5. aiogram_idle/config.py +71 -0
  6. aiogram_idle/controls.py +167 -0
  7. aiogram_idle/coordinator.py +146 -0
  8. aiogram_idle/effect_scope.py +27 -0
  9. aiogram_idle/effects.py +160 -0
  10. aiogram_idle/errors.py +61 -0
  11. aiogram_idle/events.py +21 -0
  12. aiogram_idle/failures.py +67 -0
  13. aiogram_idle/integration.py +275 -0
  14. aiogram_idle/integrations/nav/__init__.py +20 -0
  15. aiogram_idle/integrations/nav/adapters.py +187 -0
  16. aiogram_idle/integrations/nav/codec.py +78 -0
  17. aiogram_idle/integrations/nav/effects.py +98 -0
  18. aiogram_idle/integrations/nav/engine.py +245 -0
  19. aiogram_idle/integrations/nav/errors.py +25 -0
  20. aiogram_idle/integrations/nav/installation.py +295 -0
  21. aiogram_idle/integrations/nav/integration.py +245 -0
  22. aiogram_idle/integrations/nav/lifecycle.py +143 -0
  23. aiogram_idle/integrations/nav/mapping.py +106 -0
  24. aiogram_idle/integrations/nav/overlay.py +71 -0
  25. aiogram_idle/integrations/nav/policies.py +73 -0
  26. aiogram_idle/integrations/nav/presentation.py +105 -0
  27. aiogram_idle/integrations/nav/receipts.py +49 -0
  28. aiogram_idle/integrations/nav/recovery.py +119 -0
  29. aiogram_idle/integrations/nav/repository.py +375 -0
  30. aiogram_idle/integrations/nav/staging.py +195 -0
  31. aiogram_idle/machine.py +66 -0
  32. aiogram_idle/models.py +383 -0
  33. aiogram_idle/policies.py +87 -0
  34. aiogram_idle/presentation.py +299 -0
  35. aiogram_idle/protocols.py +68 -0
  36. aiogram_idle/py.typed +0 -0
  37. aiogram_idle/questions.py +184 -0
  38. aiogram_idle/receipts.py +129 -0
  39. aiogram_idle/runtime.py +385 -0
  40. aiogram_idle/storage/__init__.py +7 -0
  41. aiogram_idle/storage/base.py +118 -0
  42. aiogram_idle/storage/codec.py +292 -0
  43. aiogram_idle/storage/component_transaction.py +244 -0
  44. aiogram_idle/storage/control_tokens.py +61 -0
  45. aiogram_idle/storage/coordination.py +21 -0
  46. aiogram_idle/storage/memory.py +499 -0
  47. aiogram_idle/storage/queue.py +114 -0
  48. aiogram_idle/storage/receipts.py +99 -0
  49. aiogram_idle/storage/redis.py +352 -0
  50. aiogram_idle/storage/redis_admin.py +78 -0
  51. aiogram_idle/storage/redis_coordinator.py +311 -0
  52. aiogram_idle/storage/redis_engine.py +239 -0
  53. aiogram_idle/storage/redis_jobs.py +226 -0
  54. aiogram_idle/storage/redis_quarantine.py +142 -0
  55. aiogram_idle/storage/redis_scripts.py +162 -0
  56. aiogram_idle/storage/redis_store.py +464 -0
  57. aiogram_idle/storage/redis_transport.py +121 -0
  58. aiogram_idle/storage/retention.py +47 -0
  59. aiogram_idle/storage/state.py +51 -0
  60. aiogram_idle/worker.py +442 -0
  61. aiogram_idle-0.1.0.dist-info/METADATA +123 -0
  62. aiogram_idle-0.1.0.dist-info/RECORD +64 -0
  63. aiogram_idle-0.1.0.dist-info/WHEEL +4 -0
  64. aiogram_idle-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ ]
@@ -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")
@@ -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.