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.
- django_aiogram/__init__.py +64 -0
- django_aiogram/_singleton.py +22 -0
- django_aiogram/admin.py +380 -0
- django_aiogram/api.py +38 -0
- django_aiogram/apps.py +56 -0
- django_aiogram/config/__init__.py +15 -0
- django_aiogram/config/checks.py +759 -0
- django_aiogram/config/defaults.py +102 -0
- django_aiogram/config/enums.py +108 -0
- django_aiogram/config/settings.py +266 -0
- django_aiogram/consumer/__init__.py +13 -0
- django_aiogram/consumer/delivery.py +625 -0
- django_aiogram/consumer/routers.py +19 -0
- django_aiogram/consumer/webhook.py +154 -0
- django_aiogram/context.py +34 -0
- django_aiogram/eventlog/__init__.py +16 -0
- django_aiogram/eventlog/dbrouter.py +55 -0
- django_aiogram/eventlog/events.py +120 -0
- django_aiogram/eventlog/instrumentation.py +231 -0
- django_aiogram/eventlog/recorder.py +922 -0
- django_aiogram/eventlog/signals.py +84 -0
- django_aiogram/eventlog/writer.py +231 -0
- django_aiogram/exceptions.py +60 -0
- django_aiogram/healthcheck.py +412 -0
- django_aiogram/management/__init__.py +1 -0
- django_aiogram/management/commands/__init__.py +1 -0
- django_aiogram/management/commands/start_tgbot.py +308 -0
- django_aiogram/management/commands/tgbot_healthcheck.py +57 -0
- django_aiogram/management/commands/tgbot_prune_events.py +144 -0
- django_aiogram/management/commands/tgbot_reclaim.py +135 -0
- django_aiogram/management/commands/tgbot_webhook.py +87 -0
- django_aiogram/migrations/0001_initial.py +50 -0
- django_aiogram/migrations/0002_kind_id_index.py +32 -0
- django_aiogram/migrations/__init__.py +1 -0
- django_aiogram/models.py +79 -0
- django_aiogram/producer/__init__.py +13 -0
- django_aiogram/producer/client.py +1540 -0
- django_aiogram/producer/throttling.py +336 -0
- django_aiogram/py.typed +0 -0
- django_aiogram/redis.py +394 -0
- django_aiogram/wire/__init__.py +14 -0
- django_aiogram/wire/envelope.py +146 -0
- django_aiogram/wire/payloads.py +195 -0
- django_aiogram/wire/serializers.py +533 -0
- django_aiogram-4.0.0.dev0.dist-info/METADATA +145 -0
- django_aiogram-4.0.0.dev0.dist-info/RECORD +48 -0
- django_aiogram-4.0.0.dev0.dist-info/WHEEL +4 -0
- 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)
|