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,87 @@
|
|
|
1
|
+
"""Register, inspect or remove the Telegram webhook.
|
|
2
|
+
|
|
3
|
+
Telegram remembers the URL, not your settings file, so switching between polling
|
|
4
|
+
and webhooks means telling Telegram. `getUpdates` refuses to run while a webhook
|
|
5
|
+
is registered, which is why `delete` exists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from argparse import ArgumentParser
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from django.core.management import BaseCommand, CommandError
|
|
12
|
+
|
|
13
|
+
from django_aiogram import bot
|
|
14
|
+
from django_aiogram.config.enums import UpdateMode
|
|
15
|
+
from django_aiogram.consumer.webhook import current_mode, webhook_settings
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Command(BaseCommand):
|
|
19
|
+
"""Tell Telegram where to post updates, or that it should stop."""
|
|
20
|
+
|
|
21
|
+
help = 'Set, delete or show the Telegram webhook'
|
|
22
|
+
|
|
23
|
+
def add_arguments(self, parser: ArgumentParser) -> None:
|
|
24
|
+
"""Declare the action and --drop-pending."""
|
|
25
|
+
parser.add_argument('action', choices=['set', 'delete', 'info'])
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
'--drop-pending',
|
|
28
|
+
action='store_true',
|
|
29
|
+
help='discard the updates Telegram queued while no webhook was registered',
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
33
|
+
"""Run the chosen action, and close the bot whichever way it ends."""
|
|
34
|
+
if not bot.enabled:
|
|
35
|
+
msg = "the bot is disabled here (TELEGRAM_BOT['ENABLED'] or DJANGO_AIOGRAM_ENABLED); nothing was changed"
|
|
36
|
+
raise CommandError(msg)
|
|
37
|
+
|
|
38
|
+
action = options['action']
|
|
39
|
+
try:
|
|
40
|
+
getattr(self, f'_{action}')(options)
|
|
41
|
+
finally:
|
|
42
|
+
bot.close()
|
|
43
|
+
|
|
44
|
+
def _set(self, options: dict[str, Any]) -> None:
|
|
45
|
+
"""Register the webhook Telegram should deliver to, warning if MODE disagrees.
|
|
46
|
+
|
|
47
|
+
Setting one is what makes ``getUpdates`` start refusing, so a project still
|
|
48
|
+
configured for polling is told plainly rather than left to read that from
|
|
49
|
+
Telegram's error later.
|
|
50
|
+
"""
|
|
51
|
+
if current_mode() != UpdateMode.WEBHOOK:
|
|
52
|
+
self.stdout.write(
|
|
53
|
+
self.style.WARNING(
|
|
54
|
+
f"TELEGRAM_BOT['MODE'] is '{current_mode()}': registering this webhook "
|
|
55
|
+
'stops getUpdates from working, so polling will fail until it is deleted'
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
arguments = webhook_settings()
|
|
59
|
+
arguments['drop_pending_updates'] = options['drop_pending']
|
|
60
|
+
bot.loop.run_until_complete(bot.bot.set_webhook(**arguments))
|
|
61
|
+
self.stdout.write(self.style.SUCCESS(f'webhook set to {arguments["url"]}'))
|
|
62
|
+
self.stdout.write('polling will refuse to start until this is deleted')
|
|
63
|
+
|
|
64
|
+
def _delete(self, options: dict[str, Any]) -> None:
|
|
65
|
+
"""Unregister the webhook, which is what polling needs before it can start.
|
|
66
|
+
|
|
67
|
+
``drop_pending_updates`` decides whether Telegram forgets what it queued while
|
|
68
|
+
the webhook was down; the caller chooses, because that is a decision about
|
|
69
|
+
duplicate work rather than about the webhook.
|
|
70
|
+
"""
|
|
71
|
+
bot.loop.run_until_complete(bot.bot.delete_webhook(drop_pending_updates=options['drop_pending']))
|
|
72
|
+
self.stdout.write(self.style.SUCCESS('webhook deleted; polling can start again'))
|
|
73
|
+
|
|
74
|
+
def _info(self, _options: dict[str, Any]) -> None:
|
|
75
|
+
"""Report what Telegram thinks the webhook is, which is the only authority on it.
|
|
76
|
+
|
|
77
|
+
``last_error_message`` is the field worth the command: a webhook can be
|
|
78
|
+
registered and rejected on every delivery, and nothing local shows that.
|
|
79
|
+
"""
|
|
80
|
+
info = bot.loop.run_until_complete(bot.bot.get_webhook_info())
|
|
81
|
+
if not info.url:
|
|
82
|
+
self.stdout.write('no webhook registered; this bot is polled')
|
|
83
|
+
return
|
|
84
|
+
self.stdout.write(f'url: {info.url}')
|
|
85
|
+
self.stdout.write(f'pending updates: {info.pending_update_count}')
|
|
86
|
+
if info.last_error_message:
|
|
87
|
+
self.stdout.write(self.style.WARNING(f'last error: {info.last_error_message} (at {info.last_error_date})'))
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The first migration this package has ever shipped: the event log table."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
import django.utils.timezone
|
|
6
|
+
from django.db import migrations, models
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Migration(migrations.Migration):
|
|
10
|
+
"""Create the append-only event feed and the four indexes it is read by."""
|
|
11
|
+
|
|
12
|
+
initial = True
|
|
13
|
+
|
|
14
|
+
dependencies: ClassVar[list[tuple[str, str]]] = []
|
|
15
|
+
|
|
16
|
+
operations: ClassVar[list[migrations.operations.base.Operation]] = [
|
|
17
|
+
migrations.CreateModel(
|
|
18
|
+
name='TelegramEvent',
|
|
19
|
+
fields=[
|
|
20
|
+
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
|
21
|
+
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
|
22
|
+
('correlation_id', models.UUIDField()),
|
|
23
|
+
('kind', models.CharField(max_length=48)),
|
|
24
|
+
('function', models.CharField(blank=True, max_length=64)),
|
|
25
|
+
('chat_id', models.BigIntegerField(blank=True, null=True)),
|
|
26
|
+
('user_id', models.BigIntegerField(blank=True, null=True)),
|
|
27
|
+
('message_id', models.BigIntegerField(blank=True, null=True)),
|
|
28
|
+
('update_id', models.BigIntegerField(blank=True, null=True)),
|
|
29
|
+
('worker', models.CharField(blank=True, max_length=128)),
|
|
30
|
+
('attempt', models.PositiveSmallIntegerField(default=0)),
|
|
31
|
+
('duration_ms', models.PositiveIntegerField(blank=True, null=True)),
|
|
32
|
+
('error_code', models.CharField(blank=True, max_length=64)),
|
|
33
|
+
('error', models.TextField(blank=True)),
|
|
34
|
+
('detail', models.JSONField(blank=True, default=dict)),
|
|
35
|
+
],
|
|
36
|
+
options={
|
|
37
|
+
'verbose_name': 'telegram event',
|
|
38
|
+
'verbose_name_plural': 'telegram events',
|
|
39
|
+
'db_table': 'django_aiogram_event',
|
|
40
|
+
'ordering': ('-id',),
|
|
41
|
+
'permissions': (('view_telegramevent_payload', 'Can see event payloads and error text'),),
|
|
42
|
+
'indexes': [
|
|
43
|
+
models.Index(fields=['correlation_id'], name='drai_event_correlation'),
|
|
44
|
+
models.Index(fields=['-created_at'], name='drai_event_recent'),
|
|
45
|
+
models.Index(fields=['kind', '-created_at'], name='drai_event_kind_recent'),
|
|
46
|
+
models.Index(fields=['chat_id', '-id'], name='drai_event_chat'),
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
),
|
|
50
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Index the kind filter by the column the changelist actually orders on.
|
|
2
|
+
|
|
3
|
+
Added before the old one is removed, so the table is never without an index on
|
|
4
|
+
``kind``. A new name rather than a rename, because the columns differ: the old
|
|
5
|
+
one was ``(kind, -created_at)`` while the changelist orders by ``-id``.
|
|
6
|
+
|
|
7
|
+
``AddIndex`` issues a plain ``CREATE INDEX``, with no ``IF NOT EXISTS`` and no
|
|
8
|
+
adoption of one that is already there — so creating it by hand ahead of this
|
|
9
|
+
migration makes the migration fail rather than saving it work. On a table large
|
|
10
|
+
enough for the lock to matter, run the migration in a window.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
from django.db import migrations, models
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Migration(migrations.Migration):
|
|
19
|
+
"""Swap ``(kind, -created_at)`` for ``(kind, -id)``."""
|
|
20
|
+
|
|
21
|
+
dependencies: ClassVar[list[tuple[str, str]]] = [('django_aiogram', '0001_initial')]
|
|
22
|
+
|
|
23
|
+
operations: ClassVar[list[migrations.operations.base.Operation]] = [
|
|
24
|
+
migrations.AddIndex(
|
|
25
|
+
model_name='telegramevent',
|
|
26
|
+
index=models.Index(fields=['kind', '-id'], name='drai_event_kind_id'),
|
|
27
|
+
),
|
|
28
|
+
migrations.RemoveIndex(
|
|
29
|
+
model_name='telegramevent',
|
|
30
|
+
name='drai_event_kind_recent',
|
|
31
|
+
),
|
|
32
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Migrations for the event log table."""
|
django_aiogram/models.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""The append-only feed of what the bot did.
|
|
2
|
+
|
|
3
|
+
Django imports this on every ``django.setup()`` — before ``AppConfig.ready()``
|
|
4
|
+
and regardless of ``ENABLED`` — so it may not reach aiogram, directly or
|
|
5
|
+
otherwise, and may not read settings at import time.
|
|
6
|
+
|
|
7
|
+
Rows are inserted and never updated. The stages of one outbound message are
|
|
8
|
+
three rows sharing a ``correlation_id``: the web process writes the queued row
|
|
9
|
+
and the bot container writes the delivered one, with no coordination between
|
|
10
|
+
them and no foreign key either way. That only works because the feed is
|
|
11
|
+
insert-only, which is also what keeps pruning cheap and the table shardable.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from django.db import models
|
|
15
|
+
from django.utils import timezone
|
|
16
|
+
|
|
17
|
+
from django_aiogram.eventlog.events import MAX_KIND_LENGTH
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TelegramEvent(models.Model):
|
|
21
|
+
"""One thing that happened to one message, update or handler."""
|
|
22
|
+
|
|
23
|
+
id = models.BigAutoField(primary_key=True)
|
|
24
|
+
# stamped by whoever recorded it: the writer batches, so auto_now_add would
|
|
25
|
+
# record when the batch was flushed rather than when the thing happened
|
|
26
|
+
created_at = models.DateTimeField(default=timezone.now)
|
|
27
|
+
# time-ordered (UUIDv7), so this index appends rather than scattering
|
|
28
|
+
correlation_id = models.UUIDField()
|
|
29
|
+
# no choices: see events.kind_choices for why the registry stays in Python
|
|
30
|
+
kind = models.CharField(max_length=MAX_KIND_LENGTH)
|
|
31
|
+
|
|
32
|
+
function = models.CharField(max_length=64, blank=True)
|
|
33
|
+
chat_id = models.BigIntegerField(null=True, blank=True)
|
|
34
|
+
user_id = models.BigIntegerField(null=True, blank=True)
|
|
35
|
+
message_id = models.BigIntegerField(null=True, blank=True)
|
|
36
|
+
update_id = models.BigIntegerField(null=True, blank=True)
|
|
37
|
+
# the same name the in-flight list uses, so a row points at a container
|
|
38
|
+
worker = models.CharField(max_length=128, blank=True)
|
|
39
|
+
|
|
40
|
+
attempt = models.PositiveSmallIntegerField(default=0)
|
|
41
|
+
duration_ms = models.PositiveIntegerField(null=True, blank=True)
|
|
42
|
+
error_code = models.CharField(max_length=64, blank=True)
|
|
43
|
+
error = models.TextField(blank=True)
|
|
44
|
+
detail = models.JSONField(default=dict, blank=True)
|
|
45
|
+
|
|
46
|
+
class Meta:
|
|
47
|
+
"""Portable everywhere: no constraints, no relations, four indexes."""
|
|
48
|
+
|
|
49
|
+
db_table = 'django_aiogram_event'
|
|
50
|
+
# by id, not created_at: id is the insert order and unique, so the admin
|
|
51
|
+
# paginator gets a total order without a tie-breaker column
|
|
52
|
+
ordering = ('-id',)
|
|
53
|
+
verbose_name = 'telegram event'
|
|
54
|
+
verbose_name_plural = 'telegram events'
|
|
55
|
+
# Django's four stock permissions, plus the one it has no equivalent for:
|
|
56
|
+
# there are no field-level permissions, and seeing that a message went
|
|
57
|
+
# out is a different question from reading what it said
|
|
58
|
+
permissions = (('view_telegramevent_payload', 'Can see event payloads and error text'),)
|
|
59
|
+
# named explicitly and kept short: Oracle rejects an identifier over 30
|
|
60
|
+
indexes = (
|
|
61
|
+
models.Index(fields=('correlation_id',), name='drai_event_correlation'),
|
|
62
|
+
# two consumers: the changelist's created_at sort header and the
|
|
63
|
+
# prune watermark. Django appends -pk to make the sort deterministic,
|
|
64
|
+
# which this cannot serve on its own — measured, the descending header
|
|
65
|
+
# sorts only the last term, within rows sharing one created_at, and
|
|
66
|
+
# the ascending one needs no sort at all. A pair of (created_at, -id)
|
|
67
|
+
# indexes would remove even that, at the price of two more writes per
|
|
68
|
+
# row on a table whose whole design is cheap inserts
|
|
69
|
+
models.Index(fields=('-created_at',), name='drai_event_recent'),
|
|
70
|
+
# by -id, matching `ordering`: on (kind, -created_at) every filtered
|
|
71
|
+
# changelist sorted in a temp b-tree, page query and bounded count
|
|
72
|
+
# alike, which is what made the count's documented bound untrue
|
|
73
|
+
models.Index(fields=('kind', '-id'), name='drai_event_kind_id'),
|
|
74
|
+
models.Index(fields=('chat_id', '-id'), name='drai_event_chat'),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def __str__(self) -> str:
|
|
78
|
+
"""Name the event the way an admin row reads."""
|
|
79
|
+
return f'{self.kind} {self.function}'.strip()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The send side: what a project calls to make a message happen.
|
|
2
|
+
|
|
3
|
+
`client` is the bot and the producer — routing a send to Telegram or to the queue, and
|
|
4
|
+
owning the loop a webhook process turns. `throttling` paces what reaches Telegram.
|
|
5
|
+
|
|
6
|
+
A caller outside this package names `client` and nothing else. `throttling` is reached
|
|
7
|
+
through the bot, and its rate is a setting rather than an argument.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
#: deliberately empty: callers import from the modules in this package, not from the
|
|
11
|
+
#: package itself. A re-export here would make a second path to every name, and the one
|
|
12
|
+
#: nobody chose is the one that cannot be moved later
|
|
13
|
+
__all__: tuple[str, ...] = ()
|