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,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."""
@@ -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, ...] = ()