django-aiogram 4.0.0.dev0__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 (48) hide show
  1. django_aiogram/__init__.py +64 -0
  2. django_aiogram/_singleton.py +22 -0
  3. django_aiogram/admin.py +380 -0
  4. django_aiogram/api.py +38 -0
  5. django_aiogram/apps.py +56 -0
  6. django_aiogram/config/__init__.py +15 -0
  7. django_aiogram/config/checks.py +759 -0
  8. django_aiogram/config/defaults.py +102 -0
  9. django_aiogram/config/enums.py +108 -0
  10. django_aiogram/config/settings.py +266 -0
  11. django_aiogram/consumer/__init__.py +13 -0
  12. django_aiogram/consumer/delivery.py +625 -0
  13. django_aiogram/consumer/routers.py +19 -0
  14. django_aiogram/consumer/webhook.py +154 -0
  15. django_aiogram/context.py +34 -0
  16. django_aiogram/eventlog/__init__.py +16 -0
  17. django_aiogram/eventlog/dbrouter.py +55 -0
  18. django_aiogram/eventlog/events.py +120 -0
  19. django_aiogram/eventlog/instrumentation.py +231 -0
  20. django_aiogram/eventlog/recorder.py +922 -0
  21. django_aiogram/eventlog/signals.py +84 -0
  22. django_aiogram/eventlog/writer.py +231 -0
  23. django_aiogram/exceptions.py +60 -0
  24. django_aiogram/healthcheck.py +412 -0
  25. django_aiogram/management/__init__.py +1 -0
  26. django_aiogram/management/commands/__init__.py +1 -0
  27. django_aiogram/management/commands/start_tgbot.py +308 -0
  28. django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
  29. django_aiogram/management/commands/tgbot_prune_events.py +144 -0
  30. django_aiogram/management/commands/tgbot_reclaim.py +135 -0
  31. django_aiogram/management/commands/tgbot_webhook.py +87 -0
  32. django_aiogram/migrations/0001_initial.py +50 -0
  33. django_aiogram/migrations/0002_kind_id_index.py +32 -0
  34. django_aiogram/migrations/__init__.py +1 -0
  35. django_aiogram/models.py +79 -0
  36. django_aiogram/producer/__init__.py +13 -0
  37. django_aiogram/producer/client.py +1540 -0
  38. django_aiogram/producer/throttling.py +336 -0
  39. django_aiogram/py.typed +0 -0
  40. django_aiogram/redis.py +394 -0
  41. django_aiogram/wire/__init__.py +14 -0
  42. django_aiogram/wire/envelope.py +146 -0
  43. django_aiogram/wire/payloads.py +195 -0
  44. django_aiogram/wire/serializers.py +533 -0
  45. django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
  46. django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
  47. django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
  48. django_aiogram-4.0.0.dev0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,154 @@
1
+ """Receive updates over HTTP instead of polling for them.
2
+
3
+ Long polling needs a process that runs forever. A webhook does not: Telegram
4
+ posts each update to a URL, so the update arrives in whichever process serves
5
+ that URL — normally the web one.
6
+
7
+ The view is deliberately synchronous. An async view would run on the server's
8
+ own loop under ASGI but on a throwaway loop per request under WSGI, and the
9
+ bot's HTTP session binds to the first loop that uses it. Driving the bot's own
10
+ loop works the same under both.
11
+ """
12
+
13
+ import hmac
14
+ import json
15
+ import logging
16
+ from typing import Any
17
+
18
+ from aiogram.types import Update
19
+ from django.core.exceptions import ImproperlyConfigured
20
+ from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
21
+ from django.views.decorators.csrf import csrf_exempt
22
+ from pydantic import ValidationError
23
+
24
+ from django_aiogram import bot
25
+ from django_aiogram.config.enums import UpdateMode, choices
26
+ from django_aiogram.config.settings import SETTINGS_NAME, conf
27
+ from django_aiogram.exceptions import LoopUnavailableError
28
+
29
+ logger = logging.getLogger('django_aiogram')
30
+
31
+ #: what Telegram sends the configured secret back in
32
+ SECRET_HEADER = 'HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN' # noqa: S105 - a header name, not the secret it carries
33
+ #: plain strings, so argparse choices and messages read as the settings do
34
+ MODES = choices(UpdateMode)
35
+
36
+
37
+ def current_mode() -> str:
38
+ """Which of the two ways of receiving updates this deployment uses."""
39
+ mode = str(conf['MODE'] or '').strip().lower()
40
+ if mode not in MODES:
41
+ msg = f"{SETTINGS_NAME}['MODE'] must be one of {sorted(MODES)}, got {mode!r}."
42
+ raise ImproperlyConfigured(msg)
43
+ return mode
44
+
45
+
46
+ def webhook_secret() -> str:
47
+ """Return the shared secret, which the view refuses to run without."""
48
+ secret = str(conf['WEBHOOK_SECRET'] or '').strip()
49
+ if not secret:
50
+ msg = (
51
+ f"{SETTINGS_NAME}['WEBHOOK_SECRET'] is required to serve the webhook: without it "
52
+ 'anyone who finds the URL can feed your bot updates.'
53
+ )
54
+ raise ImproperlyConfigured(msg)
55
+ return secret
56
+
57
+
58
+ @csrf_exempt
59
+ def telegram_webhook(request: HttpRequest) -> HttpResponse: # noqa: PLR0911 - a guard-clause chain is the readable shape
60
+ """Feed one update to the dispatcher.
61
+
62
+ Answers 200 for anything Telegram should not retry, including a handler that
63
+ raised — a non-2xx makes Telegram redeliver the same update, and a handler
64
+ that fails once will fail again. A *refusal* is the other case: when nothing
65
+ ran at all, because this process is shutting down, redelivery is exactly
66
+ what should happen, so that answers 503.
67
+ """
68
+ if request.method != 'POST':
69
+ return HttpResponseNotAllowed(['POST'])
70
+
71
+ if not bot.enabled:
72
+ logger.warning('webhook received an update while the bot is disabled')
73
+ return HttpResponse(status=503)
74
+
75
+ try:
76
+ # guarded, because an unknown `MODE` and an empty `WEBHOOK_SECRET` each raise
77
+ # `ImproperlyConfigured` — and unguarded that left an unauthenticated 500 with a
78
+ # traceback from a view whose every other refusal is a status code. The bot build
79
+ # below already treats a configuration failure as ours to answer for.
80
+ #
81
+ # The secret only when the mode says to serve: a polling deployment has no reason
82
+ # to set one, and reading it first told every such deployment its configuration was
83
+ # unreadable instead of that it polls. Each setting is judged where it matters, and
84
+ # the order the documentation promises is the order here
85
+ mode = current_mode()
86
+ polling = mode != UpdateMode.WEBHOOK
87
+ secret = '' if polling else webhook_secret()
88
+ except ImproperlyConfigured:
89
+ logger.exception('webhook is not configured to serve updates')
90
+ return HttpResponse(status=503)
91
+
92
+ if polling:
93
+ # serving updates here while a worker polls for them would mean two
94
+ # sources of updates and no way to tell which handled what
95
+ logger.warning(
96
+ 'webhook received an update while this deployment polls',
97
+ extra={'tg_mode': mode},
98
+ )
99
+ return HttpResponse(status=503)
100
+
101
+ given = request.META.get(SECRET_HEADER, '')
102
+ # bytes, not str: `compare_digest` refuses str arguments outside ASCII, so a header
103
+ # with one non-ASCII character used to raise TypeError here — an unauthenticated
104
+ # 500 with a traceback, from the branch whose whole job is to answer 403
105
+ if not hmac.compare_digest(given.encode(), secret.encode()):
106
+ logger.warning('webhook rejected an update with a wrong secret')
107
+ return HttpResponse(status=403)
108
+
109
+ try:
110
+ telegram = bot.bot
111
+ except ImproperlyConfigured:
112
+ # a missing token is our problem, not a bad request
113
+ logger.exception('webhook cannot build the bot')
114
+ return HttpResponse(status=503)
115
+
116
+ try:
117
+ payload = json.loads(request.body)
118
+ update = Update.model_validate(payload, context={'bot': telegram})
119
+ except (json.JSONDecodeError, UnicodeDecodeError, ValidationError, TypeError) as error:
120
+ # the body is whoever posted it, so the type is all that goes in the log:
121
+ # a traceback here would spread unvalidated input through the handlers
122
+ logger.warning(
123
+ 'webhook could not read an update',
124
+ extra={'tg_error': type(error).__name__},
125
+ )
126
+ return HttpResponse(status=400)
127
+
128
+ try:
129
+ bot.feed_update(update)
130
+ except LoopUnavailableError:
131
+ # nothing ran, so this update is still Telegram's to redeliver — which a
132
+ # 2xx would tell it not to. The shutdown window is not a handler that
133
+ # failed, and the two must not answer the same way
134
+ logger.warning('webhook refused an update', extra={'tg_update': update.update_id})
135
+ return HttpResponse(status=503)
136
+ except Exception:
137
+ logger.exception('webhook handler failed', extra={'tg_update': update.update_id})
138
+
139
+ return HttpResponse(status=200)
140
+
141
+
142
+ def webhook_settings() -> dict[str, Any]:
143
+ """Everything `setWebhook` needs, resolved from settings."""
144
+ url = str(conf['WEBHOOK_URL'] or '').strip()
145
+ if not url:
146
+ msg = f"{SETTINGS_NAME}['WEBHOOK_URL'] is required to register a webhook."
147
+ raise ImproperlyConfigured(msg)
148
+ allowed = conf['WEBHOOK_ALLOWED_UPDATES']
149
+ return {
150
+ 'url': url,
151
+ 'secret_token': webhook_secret(),
152
+ 'allowed_updates': list(allowed) if allowed else None,
153
+ 'drop_pending_updates': False,
154
+ }
@@ -0,0 +1,34 @@
1
+ """The correlation identifier in scope, so a reply inherits the update's id.
2
+
3
+ A handler that answers an update should produce rows joinable to the update that
4
+ caused it, without every project threading an argument through its own code. A
5
+ context variable does that: aiogram runs each update's chain in its own task,
6
+ and a task copies the context at creation, so one update cannot leak into
7
+ another.
8
+
9
+ Read it **before** scheduling, never inside the coroutine. ``_hand_off`` creates
10
+ its task from a ``call_soon_threadsafe`` callback whose context belongs to the
11
+ loop rather than to the handler, so a read from in there would come back empty.
12
+ """
13
+
14
+ import contextlib
15
+ import uuid
16
+ from collections.abc import Iterator
17
+ from contextvars import ContextVar
18
+
19
+ _current: ContextVar[uuid.UUID | None] = ContextVar('django_aiogram_correlation_id', default=None)
20
+
21
+
22
+ def current_correlation_id() -> uuid.UUID | None:
23
+ """Return the identifier of whatever is being handled here, if anything is."""
24
+ return _current.get()
25
+
26
+
27
+ @contextlib.contextmanager
28
+ def correlation_scope(correlation_id: uuid.UUID) -> Iterator[None]:
29
+ """Run a block with this identifier in scope, restoring the previous one."""
30
+ token = _current.set(correlation_id)
31
+ try:
32
+ yield
33
+ finally:
34
+ _current.reset(token)
@@ -0,0 +1,16 @@
1
+ """The optional table, and the metrics seam that is not the table.
2
+
3
+ `recorder` is the writer thread and its accounting; `writer` puts a batch into the
4
+ database; `events` names an event and the worker that produced it; `instrumentation`
5
+ describes an update without keeping its contents; `signals` is the seam a project connects
6
+ a metrics exporter to; `dbrouter` sends this app's tables to their own alias.
7
+
8
+ The model itself is `django_aiogram.models`, at the app root, because Django looks for it
9
+ there and moving it would cost an `app_label` on the model and a `MIGRATION_MODULES` in
10
+ every project.
11
+ """
12
+
13
+ #: deliberately empty: callers import from the modules in this package, not from the
14
+ #: package itself. A re-export here would make a second path to every name, and the one
15
+ #: nobody chose is the one that cannot be moved later
16
+ __all__: tuple[str, ...] = ()
@@ -0,0 +1,55 @@
1
+ """Send the event log to its own database, when one is configured.
2
+
3
+ Optional. The writer and the admin always name the alias explicitly, so the log
4
+ lands in the right database with no router installed at all. What the router
5
+ adds is ``migrate`` creating the table there, and third-party code reaching the
6
+ right alias through the plain manager.
7
+
8
+ Not ``routers.py``: that name already means aiogram router autodiscovery.
9
+ """
10
+
11
+ from typing import Any
12
+
13
+ from django.db.models import Model
14
+
15
+ from django_aiogram.config.settings import conf
16
+
17
+ APP_LABEL = 'django_aiogram'
18
+
19
+
20
+ def event_log_database() -> str | None:
21
+ """Return the configured alias for the log, or None when it lives in the default one."""
22
+ return str(conf['EVENT_LOG_DATABASE'] or '').strip() or None
23
+
24
+
25
+ class TelegramEventLogRouter:
26
+ """Routes this app's models to ``EVENT_LOG_DATABASE`` and nothing else anywhere."""
27
+
28
+ def _alias_for(self, model: type[Model]) -> str | None:
29
+ """Return the alias this app's models belong to, or None to express no opinion."""
30
+ alias = event_log_database()
31
+ # _meta is how Django itself asks a model which app it belongs to
32
+ return alias if alias and model._meta.app_label == APP_LABEL else None # noqa: SLF001
33
+
34
+ def db_for_read(self, model: type[Model], **hints: Any) -> str | None:
35
+ """Read this app's models from the log database."""
36
+ return self._alias_for(model)
37
+
38
+ def db_for_write(self, model: type[Model], **hints: Any) -> str | None:
39
+ """Write this app's models to the log database."""
40
+ return self._alias_for(model)
41
+
42
+ def allow_relation(self, *objects: Model, **hints: Any) -> bool | None:
43
+ """Express no opinion: this app owns no relations in either direction."""
44
+ return None
45
+
46
+ def allow_migrate(self, db: str, app_label: str, **hints: Any) -> bool | None:
47
+ """Create the table on the log database only, and only when one is set.
48
+
49
+ None rather than False for other apps: where somebody else's table
50
+ belongs is not this router's decision to make.
51
+ """
52
+ alias = event_log_database()
53
+ if alias is None or app_label != APP_LABEL:
54
+ return None
55
+ return db == alias
@@ -0,0 +1,120 @@
1
+ """The registry of event kinds, and the identifier that ties a message together.
2
+
3
+ ``kind`` is an unconstrained ``CharField``: the set of legal values lives here,
4
+ in Python, so registering one is not a schema change. The cost is that nothing
5
+ at the database level rejects a typo, which is why the recorder validates
6
+ against this registry before a row is built.
7
+
8
+ Imported by :mod:`django_aiogram.models`, so it must stay free of aiogram
9
+ and of anything that reads Django settings at import time.
10
+ """
11
+
12
+ import os
13
+ import socket
14
+ import time
15
+ import uuid
16
+ from dataclasses import dataclass
17
+
18
+ from django_aiogram.config.enums import EventKind
19
+ from django_aiogram.config.settings import conf
20
+
21
+ #: the width of the model's ``kind`` column; a longer code would be truncated by
22
+ #: MySQL in non-strict mode and rejected in strict mode, so it is refused here
23
+ MAX_KIND_LENGTH = 48
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class EventKindSpec:
28
+ """One registered kind: the stored code, its label, and whether it is bad news."""
29
+
30
+ code: str
31
+ label: str
32
+ failure: bool = False
33
+
34
+
35
+ _KINDS: dict[str, EventKindSpec] = {}
36
+
37
+
38
+ def register_kind(code: str, label: str, *, failure: bool = False) -> str:
39
+ """Register an event kind, and return its code.
40
+
41
+ Adding a kind is not a schema change. Namespace your own as
42
+ ``<app>.<noun>.<verb>`` and keep the total in the tens: ``kind`` is the
43
+ leading column of an index, and it stays worth having only while its
44
+ cardinality is low.
45
+ """
46
+ if len(code) > MAX_KIND_LENGTH:
47
+ msg = f'Event kind {code!r} is longer than {MAX_KIND_LENGTH} characters.'
48
+ raise ValueError(msg)
49
+ spec = EventKindSpec(code, label, failure=failure)
50
+ existing = _KINDS.get(code)
51
+ if existing is not None and existing != spec:
52
+ msg = f'Event kind {code!r} is already registered as {existing.label!r}.'
53
+ raise ValueError(msg)
54
+ _KINDS[code] = spec
55
+ return code
56
+
57
+
58
+ def known_kinds() -> frozenset[str]:
59
+ """Every registered code, for membership checks."""
60
+ return frozenset(_KINDS)
61
+
62
+
63
+ def kind_choices() -> list[tuple[str, str]]:
64
+ """Every registered kind as Django choices, for the admin filter.
65
+
66
+ Deliberately not passed to the model field: a callable there would have to
67
+ survive ``deconstruct()`` unexpanded, and if it ever did not, registering a
68
+ kind would become a migration every consumer has to run.
69
+ """
70
+ return [(spec.code, spec.label) for spec in _KINDS.values()]
71
+
72
+
73
+ def failure_kinds() -> tuple[str, ...]:
74
+ """Return the kinds that mean something went wrong."""
75
+ return tuple(spec.code for spec in _KINDS.values() if spec.failure)
76
+
77
+
78
+ def new_correlation_id() -> uuid.UUID:
79
+ """Build a time-ordered identifier, so its index appends instead of scattering.
80
+
81
+ uuid4 is uniformly random, and inserting random keys into a large B-tree
82
+ touches a different leaf page every time. RFC 9562's version 7 puts a 48-bit
83
+ millisecond prefix first, which sorts the same way on every backend —
84
+ PostgreSQL compares the raw bytes, and the others store lowercase hex.
85
+ """
86
+ generate = getattr(uuid, 'uuid7', None)
87
+ if generate is not None: # pragma: no cover - 3.14 and newer
88
+ return generate() # type: ignore[no-any-return]
89
+ raw = bytearray(int(time.time() * 1000).to_bytes(6, 'big') + os.urandom(10))
90
+ raw[6] = (raw[6] & 0x0F) | 0x70
91
+ raw[8] = (raw[8] & 0x3F) | 0x80
92
+ return uuid.UUID(bytes=bytes(raw))
93
+
94
+
95
+ def worker_identity() -> str:
96
+ """Name this process, for the in-flight list and for the rows it records.
97
+
98
+ Defaults to the hostname, which a container keeps across restarts — that is
99
+ what lets a restarted worker find its own interrupted messages. Set
100
+ WORKER_NAME when several workers share a host.
101
+ """
102
+ configured = conf.get('WORKER_NAME')
103
+ if configured:
104
+ return str(configured)
105
+ return os.environ.get('HOSTNAME') or socket.gethostname()
106
+
107
+
108
+ register_kind(EventKind.OUTBOUND_QUEUED.value, 'Queued')
109
+ register_kind(EventKind.OUTBOUND_CONSUMED.value, 'Taken off the queue')
110
+ register_kind(EventKind.OUTBOUND_SENT.value, 'Sent')
111
+ register_kind(EventKind.OUTBOUND_RETRIED.value, 'Rate limited, retrying', failure=True)
112
+ register_kind(EventKind.OUTBOUND_FAILED.value, 'Send failed', failure=True)
113
+ register_kind(EventKind.OUTBOUND_DROPPED.value, 'Dropped', failure=True)
114
+ register_kind(EventKind.INBOUND_RECEIVED.value, 'Update received')
115
+ register_kind(EventKind.INBOUND_HANDLED.value, 'Update handled')
116
+ register_kind(EventKind.INBOUND_FAILED.value, 'Handler raised', failure=True)
117
+ register_kind(EventKind.FSM_TRANSITION.value, 'FSM transition')
118
+ register_kind(EventKind.QUEUE_UNDECODABLE.value, 'Undecodable payload', failure=True)
119
+ register_kind(EventKind.QUEUE_REJECTED.value, 'Rejected payload', failure=True)
120
+ register_kind(EventKind.LOG_DROPPED.value, 'Events dropped', failure=True)
@@ -0,0 +1,231 @@
1
+ """Watching what comes in, through the one hook both update sources pass.
2
+
3
+ Polling and webhook both feed the same ``Dispatcher``, so a single update
4
+ middleware sees every update exactly once. When the log is off nothing is
5
+ registered at all — the cost per update is zero rather than one branch.
6
+
7
+ FSM transitions come from wrapping the configured storage rather than diffing
8
+ state around the handler: ``set_state`` *is* the transition, so it costs no
9
+ extra round trip and misses none of the ones that happen inside a nested call,
10
+ a filter or a scene. Append-only makes the previous state unnecessary — the
11
+ previous row for the same key is it.
12
+ """
13
+
14
+ import logging
15
+ import time
16
+ import uuid
17
+ from collections.abc import Mapping
18
+ from dataclasses import dataclass
19
+ from typing import Any
20
+
21
+ from aiogram import BaseMiddleware, Dispatcher
22
+ from aiogram.dispatcher.event.bases import CancelHandler
23
+ from aiogram.fsm.state import State
24
+ from aiogram.fsm.storage.base import BaseStorage, StateType, StorageKey
25
+ from aiogram.types import TelegramObject, Update
26
+ from aiogram.types.update import UpdateTypeLookupError
27
+
28
+ from django_aiogram.config.enums import EventKind
29
+ from django_aiogram.context import correlation_scope, current_correlation_id
30
+ from django_aiogram.eventlog.events import new_correlation_id
31
+ from django_aiogram.eventlog.recorder import Event, recorder
32
+ from django_aiogram.wire.payloads import describe
33
+
34
+ logger = logging.getLogger('django_aiogram')
35
+
36
+
37
+ def state_name(state: StateType) -> str | None:
38
+ """Name a state the way a reader recognizes it, whatever shape it arrives in."""
39
+ if state is None:
40
+ return None
41
+ if isinstance(state, State):
42
+ return state.state
43
+ return str(state)
44
+
45
+
46
+ def event_type(update: Update) -> str:
47
+ """Name the update's type, or nothing when this aiogram does not know it.
48
+
49
+ `Update.event_type` raises rather than returning None, and a Bot API newer
50
+ than the installed aiogram is exactly when it does. aiogram itself treats
51
+ that as an update to skip, so the log must not be what turns it into an
52
+ error.
53
+ """
54
+ try:
55
+ return update.event_type
56
+ except UpdateTypeLookupError:
57
+ return ''
58
+
59
+
60
+ def describe_update(update: Update) -> dict[str, Any]:
61
+ """Summarize an update, under the same payload policy a send obeys."""
62
+ message = update.message or update.edited_message
63
+ query = update.callback_query
64
+ return describe(
65
+ {
66
+ 'type': event_type(update) or None,
67
+ 'text': getattr(message, 'text', None),
68
+ 'data': getattr(query, 'data', None),
69
+ }
70
+ )
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Inbound:
75
+ """What every row about one update needs to name itself."""
76
+
77
+ correlation_id: uuid.UUID
78
+ update_id: int | None
79
+ chat_id: int | None
80
+ user_id: int | None
81
+ started: float
82
+
83
+
84
+ class RecordingMiddleware(BaseMiddleware):
85
+ """One row per update, and the correlation scope its replies inherit."""
86
+
87
+ async def __call__(
88
+ self,
89
+ handler: Any, # noqa: ANN401 - aiogram types this as a bare callable
90
+ event: TelegramObject,
91
+ data: dict[str, Any],
92
+ ) -> Any: # noqa: ANN401 - whatever the handler chain returns
93
+ """Record the update, then run the handlers inside its correlation scope."""
94
+ if not isinstance(event, Update):
95
+ return await handler(event, data)
96
+
97
+ inbound = Inbound(
98
+ correlation_id=new_correlation_id(),
99
+ update_id=event.update_id,
100
+ chat_id=getattr(data.get('event_chat'), 'id', None),
101
+ user_id=getattr(data.get('event_from_user'), 'id', None),
102
+ started=time.monotonic(),
103
+ )
104
+ identifier = inbound.correlation_id
105
+ data['correlation_id'] = identifier
106
+
107
+ recorder.record(
108
+ Event(
109
+ kind=EventKind.INBOUND_RECEIVED.value,
110
+ correlation_id=identifier,
111
+ update_id=inbound.update_id,
112
+ function=event_type(event),
113
+ chat_id=inbound.chat_id,
114
+ user_id=inbound.user_id,
115
+ # summarized only for the table: a receiver counting updates has no
116
+ # use for the text, and this is the costly part of recording one
117
+ detail=describe_update(event) if recorder.wants_payload else None,
118
+ )
119
+ )
120
+
121
+ # everything a handler sends inherits this id, so a reply is joined to
122
+ # the update that caused it without any project code passing it along
123
+ with correlation_scope(identifier):
124
+ try:
125
+ result = await handler(event, data)
126
+ except CancelHandler:
127
+ raise
128
+ except Exception as error:
129
+ self._record(
130
+ EventKind.INBOUND_FAILED,
131
+ inbound,
132
+ error=error,
133
+ raw_state=data.get('raw_state'),
134
+ )
135
+ raise
136
+ self._record(EventKind.INBOUND_HANDLED, inbound, raw_state=data.get('raw_state'))
137
+ return result
138
+
139
+ @staticmethod
140
+ def _record(
141
+ kind: EventKind,
142
+ inbound: 'Inbound',
143
+ *,
144
+ error: BaseException | None = None,
145
+ raw_state: object = None,
146
+ ) -> None:
147
+ """Record the outcome of one update, with how long the handlers took."""
148
+ recorder.record(
149
+ Event(
150
+ kind=kind.value,
151
+ correlation_id=inbound.correlation_id,
152
+ update_id=inbound.update_id,
153
+ chat_id=inbound.chat_id,
154
+ user_id=inbound.user_id,
155
+ duration_ms=int((time.monotonic() - inbound.started) * 1000),
156
+ error_code=type(error).__name__ if error is not None else '',
157
+ error=str(error) if error is not None else '',
158
+ detail={'state_before': raw_state} if raw_state else {},
159
+ )
160
+ )
161
+
162
+
163
+ class RecordingStorage(BaseStorage):
164
+ """Forwards to the configured storage, recording every state change.
165
+
166
+ ``set_state`` is the transition, so this needs no extra read and misses
167
+ none — including the ones a filter or a scene makes.
168
+ """
169
+
170
+ def __init__(self, inner: BaseStorage) -> None:
171
+ """Hold the storage everything is forwarded to."""
172
+ self.inner = inner
173
+
174
+ async def set_state(self, key: StorageKey, state: StateType = None) -> None:
175
+ """Set the state, then record the transition it just made."""
176
+ await self.inner.set_state(key, state)
177
+ recorder.record(
178
+ Event(
179
+ kind=EventKind.FSM_TRANSITION.value,
180
+ correlation_id=current_correlation_id() or new_correlation_id(),
181
+ chat_id=key.chat_id,
182
+ user_id=key.user_id,
183
+ detail={'to': state_name(state), 'destiny': key.destiny, 'thread_id': key.thread_id},
184
+ )
185
+ )
186
+
187
+ async def get_state(self, key: StorageKey) -> str | None:
188
+ """Forward unchanged: reading a state is not a transition."""
189
+ return await self.inner.get_state(key)
190
+
191
+ async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None:
192
+ """Forward unchanged."""
193
+ await self.inner.set_data(key, data)
194
+
195
+ async def get_data(self, key: StorageKey) -> dict[str, Any]:
196
+ """Forward unchanged."""
197
+ return await self.inner.get_data(key)
198
+
199
+ async def update_data(self, key: StorageKey, data: Mapping[str, Any]) -> dict[str, Any]:
200
+ """Forward rather than inherit: a storage may make this one round trip."""
201
+ return await self.inner.update_data(key, data)
202
+
203
+ async def close(self) -> None:
204
+ """Forward: TelegramBot.close releases the storage through this."""
205
+ await self.inner.close()
206
+
207
+
208
+ def install_instrumentation(dispatcher: Dispatcher) -> None:
209
+ """Register the update middleware, unless nothing is reading events.
210
+
211
+ Returning before anything is built is what makes the inactive cost zero:
212
+ there is no middleware in the chain at all, not one that checks a flag.
213
+
214
+ Read once, when the dispatcher is built — so a receiver connected after the
215
+ first update arrives will not see updates in this process. Connect them while
216
+ the apps load, which is where Django says signal receivers belong anyway.
217
+ """
218
+ if not recorder.active:
219
+ return
220
+ dispatcher.update.outer_middleware.register(RecordingMiddleware())
221
+
222
+
223
+ def instrumented(storage: BaseStorage) -> BaseStorage:
224
+ """Wrap the storage when something reads events, and hand it back untouched when not.
225
+
226
+ Same one-shot reading as :func:`install_instrumentation`, and for the same
227
+ reason: the storage is built once.
228
+ """
229
+ if not recorder.active:
230
+ return storage
231
+ return RecordingStorage(storage)