tgdata 0.1__tar.gz → 0.1.1__tar.gz
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.
- {tgdata-0.1 → tgdata-0.1.1}/PKG-INFO +67 -1
- {tgdata-0.1 → tgdata-0.1.1}/README.md +66 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/__init__.py +7 -1
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/account_pool.py +16 -12
- tgdata-0.1.1/tgdata/album.py +246 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/backfill_engine.py +5 -2
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/batch_engine.py +19 -3
- tgdata-0.1.1/tgdata/batch_heartbeat.py +150 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/connection_engine.py +5 -4
- tgdata-0.1.1/tgdata/photo_trails.py +393 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/progress_facade.py +9 -4
- tgdata-0.1.1/tgdata/smoke_tests/test_42_album_assembly.py +951 -0
- tgdata-0.1.1/tgdata/smoke_tests/test_43_photo_trails.py +695 -0
- tgdata-0.1.1/tgdata/smoke_tests/test_44_batch_heartbeat.py +451 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/sync_engine.py +5 -2
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/tgdata.py +7 -2
- {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/PKG-INFO +67 -1
- {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/SOURCES.txt +6 -0
- {tgdata-0.1 → tgdata-0.1.1}/setup.cfg +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/setup.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/account_operation.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/backfill.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/backfill_state.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/batch_files.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/budget_client.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/discovery_engine.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/group_operations.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/health.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/history_window.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/join_budget.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/join_client.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/login.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/message_batch.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/message_engine.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/models.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/owned_health.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_lifecycle.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_observation.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_result.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_source.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_state.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/progress.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/read_budget.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/session_store.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/__init__.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/my_test.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/pool_storage_cases.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_00_connection.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_01_list_group_chats.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_02_get_message_count.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_03_get_all_messages.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_04_get_all_messages_in_batches.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_05_with_progress_feature.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_06_polling_simple.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_07_on_new_message_simple.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_07_real_time_event_handler.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_08_batch_callback.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_08_polling.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_10_advanced_features.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_11_discover_groups.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_12_proxy.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_13_device_identity.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_14_flood_threshold.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_15_login_checks.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_16_health_events.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_17_session_store.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_18_read_budget.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_19_message_batches.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_22_daily_continuation.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_23_fixed_windows.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_24_backfill_state.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_25_backfill_delivery.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_26_backfill_completion.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_27_backfill_pacing.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_28_backfill_controls.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_29_backfill_public.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_30_backfill_integration.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_31_account_pool.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_32_account_operation.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_33_owned_health.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_34_group_access.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_35_join_budget.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_36_group_join.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_37_worker.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_38_worker_process.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_39_pool_lifecycle.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_40_pool_outcomes.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_41_stepwise_login.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_debug_min_id.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_polling_debug.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_username_support.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/sync_store.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/utils.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/worker.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/worker_engine.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata/worker_protocol.py +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/dependency_links.txt +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/entry_points.txt +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/requires.txt +0 -0
- {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tgdata
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: A production-grade Python library for extracting and processing Telegram group and channel messages
|
|
5
5
|
Author: enes kuzucu
|
|
6
6
|
Classifier: Development Status :: 3 - Alpha
|
|
@@ -59,6 +59,8 @@ A production-grade Python library for extracting and processing Telegram group a
|
|
|
59
59
|
- 🩺 **Account Health Events**: Every wait, logout, ban, restriction or lost group is reported as a plain-data event — through one callback, a log line and a per-account summary — and "ok" when it ends
|
|
60
60
|
- **Per-account read budgets**: An optional shared SQLite ledger limits message pulls over a rolling 24 hours, with persistent warm-up steps and recoverable partial results
|
|
61
61
|
- **Versioned message batches**: Read bounded raw snapshots with canonical JSON, replayable batch IDs, safe continuation cursors and optional content-addressed media
|
|
62
|
+
- **Album assembly across batches**: Combine observed album members into stable post revisions, preserving every caption and media reference with explicit caller-owned persistence
|
|
63
|
+
- **Optional photo trails**: Associate a user's following photo-only posts in a provisional view, retaining all originals and explicit heuristic provenance
|
|
62
64
|
- **Durable daily continuation**: Keep per-group progress and pending batches in a pluggable store, replay after restart, and advance only after explicit acknowledgment
|
|
63
65
|
- **Reading worker**: Run a serial process that accepts explicit-account JSON jobs for batches, group lookup, access checks and joining, with correlated health replies
|
|
64
66
|
|
|
@@ -807,6 +809,70 @@ refuses cleanup may leave a private temporary file. The exact schema, limits,
|
|
|
807
809
|
interruption rules
|
|
808
810
|
and delivery protocol are in [Message batch v1](docs/message_batch_v1.md).
|
|
809
811
|
|
|
812
|
+
### Albums across message batches
|
|
813
|
+
|
|
814
|
+
Use the opt-in assembler to keep one logical post as more album members arrive:
|
|
815
|
+
|
|
816
|
+
```python
|
|
817
|
+
from tgdata import album_post_ids, assemble_albums
|
|
818
|
+
|
|
819
|
+
# In-memory illustration for a new collection; retain these posts across calls.
|
|
820
|
+
known_posts = {}
|
|
821
|
+
for batch in batches:
|
|
822
|
+
previous = {key: known_posts.get(key) for key in album_post_ids(batch)}
|
|
823
|
+
for post in assemble_albums(batch, previous=previous):
|
|
824
|
+
known_posts[post.post_id] = post
|
|
825
|
+
print(post.post_id, post.revision, post.member_ids)
|
|
826
|
+
```
|
|
827
|
+
|
|
828
|
+
Album IDs include the chat; each post retains all original member records and
|
|
829
|
+
captions. Identical replay keeps the same revision. Album membership remains
|
|
830
|
+
`unconfirmed`: a batch limit, fixed window or history end cannot prove that every
|
|
831
|
+
member was seen. Assembly performs no additional reads and changes no raw batch
|
|
832
|
+
hash, cursor or acknowledgment.
|
|
833
|
+
|
|
834
|
+
For restartable collection, commit the original records and input recognition in
|
|
835
|
+
your destination **before** acknowledging the raw delivery. Confirm an already
|
|
836
|
+
accepted input from those records without rebuilding a growing album. The SQLite
|
|
837
|
+
example's `accept` returns no post payload; `load(post_id)` reads the current view
|
|
838
|
+
separately. Do not replace lost destination state with an empty dictionary. The
|
|
839
|
+
[album contract](docs/album_assembly.md) explains updates, limits and storage;
|
|
840
|
+
`python examples/album_assembly.py --demo` demonstrates SQLite restart and lost-ack
|
|
841
|
+
replay without Telegram.
|
|
842
|
+
|
|
843
|
+
### Batch-reader liveness
|
|
844
|
+
|
|
845
|
+
Pass a quick synchronous `heartbeat(phase)` to `get_message_batch`, `sync_group`
|
|
846
|
+
or `prepare_backfill` (also through AccountPool) to observe message, media-chunk
|
|
847
|
+
and request progress. This lets a caller distinguish a slow download from a
|
|
848
|
+
silent read. Defaults, batch hashes and acknowledgments stay unchanged. See
|
|
849
|
+
[heartbeat and watchdog guidance](docs/batch_heartbeat.md) for callback isolation,
|
|
850
|
+
replay behavior and the SDK-wait limit.
|
|
851
|
+
|
|
852
|
+
### Optional photo trails
|
|
853
|
+
|
|
854
|
+
For a text or album followed by additional photo-only posts, explicitly enable
|
|
855
|
+
the conservative heuristic over your current original observations:
|
|
856
|
+
|
|
857
|
+
```python
|
|
858
|
+
from tgdata import PhotoTrailPolicy, fold_photo_trails
|
|
859
|
+
|
|
860
|
+
view = fold_photo_trails(current_posts, policy=PhotoTrailPolicy(enabled=True))
|
|
861
|
+
print(view.groups) # tuples of original post IDs, anchor first
|
|
862
|
+
print(view.folded_posts, view.folded_photos)
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
The default policy is disabled. Every original `TelegramPost` and caption remains
|
|
866
|
+
in `view.posts`. A later observation can withdraw an inferred association; replace
|
|
867
|
+
the earlier view for your chosen scope instead of appending final posts. Counts
|
|
868
|
+
describe the current view, not additional work on each retry.
|
|
869
|
+
|
|
870
|
+
The helper makes no source requests or acknowledgments. The optional SQLite
|
|
871
|
+
example reuses saved original records and refuses a range that cuts a known
|
|
872
|
+
album, preserving intervening context. See [photo-trail views](docs/photo_trails.md)
|
|
873
|
+
for policy, limits and restart/acknowledgment guidance, or run
|
|
874
|
+
`python examples/photo_trails.py --demo` without Telegram.
|
|
875
|
+
|
|
810
876
|
### Daily continuation across restarts
|
|
811
877
|
|
|
812
878
|
Use a progress store when tgdata should remember unfinished delivery and the last
|
|
@@ -30,6 +30,8 @@ A production-grade Python library for extracting and processing Telegram group a
|
|
|
30
30
|
- 🩺 **Account Health Events**: Every wait, logout, ban, restriction or lost group is reported as a plain-data event — through one callback, a log line and a per-account summary — and "ok" when it ends
|
|
31
31
|
- **Per-account read budgets**: An optional shared SQLite ledger limits message pulls over a rolling 24 hours, with persistent warm-up steps and recoverable partial results
|
|
32
32
|
- **Versioned message batches**: Read bounded raw snapshots with canonical JSON, replayable batch IDs, safe continuation cursors and optional content-addressed media
|
|
33
|
+
- **Album assembly across batches**: Combine observed album members into stable post revisions, preserving every caption and media reference with explicit caller-owned persistence
|
|
34
|
+
- **Optional photo trails**: Associate a user's following photo-only posts in a provisional view, retaining all originals and explicit heuristic provenance
|
|
33
35
|
- **Durable daily continuation**: Keep per-group progress and pending batches in a pluggable store, replay after restart, and advance only after explicit acknowledgment
|
|
34
36
|
- **Reading worker**: Run a serial process that accepts explicit-account JSON jobs for batches, group lookup, access checks and joining, with correlated health replies
|
|
35
37
|
|
|
@@ -778,6 +780,70 @@ refuses cleanup may leave a private temporary file. The exact schema, limits,
|
|
|
778
780
|
interruption rules
|
|
779
781
|
and delivery protocol are in [Message batch v1](docs/message_batch_v1.md).
|
|
780
782
|
|
|
783
|
+
### Albums across message batches
|
|
784
|
+
|
|
785
|
+
Use the opt-in assembler to keep one logical post as more album members arrive:
|
|
786
|
+
|
|
787
|
+
```python
|
|
788
|
+
from tgdata import album_post_ids, assemble_albums
|
|
789
|
+
|
|
790
|
+
# In-memory illustration for a new collection; retain these posts across calls.
|
|
791
|
+
known_posts = {}
|
|
792
|
+
for batch in batches:
|
|
793
|
+
previous = {key: known_posts.get(key) for key in album_post_ids(batch)}
|
|
794
|
+
for post in assemble_albums(batch, previous=previous):
|
|
795
|
+
known_posts[post.post_id] = post
|
|
796
|
+
print(post.post_id, post.revision, post.member_ids)
|
|
797
|
+
```
|
|
798
|
+
|
|
799
|
+
Album IDs include the chat; each post retains all original member records and
|
|
800
|
+
captions. Identical replay keeps the same revision. Album membership remains
|
|
801
|
+
`unconfirmed`: a batch limit, fixed window or history end cannot prove that every
|
|
802
|
+
member was seen. Assembly performs no additional reads and changes no raw batch
|
|
803
|
+
hash, cursor or acknowledgment.
|
|
804
|
+
|
|
805
|
+
For restartable collection, commit the original records and input recognition in
|
|
806
|
+
your destination **before** acknowledging the raw delivery. Confirm an already
|
|
807
|
+
accepted input from those records without rebuilding a growing album. The SQLite
|
|
808
|
+
example's `accept` returns no post payload; `load(post_id)` reads the current view
|
|
809
|
+
separately. Do not replace lost destination state with an empty dictionary. The
|
|
810
|
+
[album contract](docs/album_assembly.md) explains updates, limits and storage;
|
|
811
|
+
`python examples/album_assembly.py --demo` demonstrates SQLite restart and lost-ack
|
|
812
|
+
replay without Telegram.
|
|
813
|
+
|
|
814
|
+
### Batch-reader liveness
|
|
815
|
+
|
|
816
|
+
Pass a quick synchronous `heartbeat(phase)` to `get_message_batch`, `sync_group`
|
|
817
|
+
or `prepare_backfill` (also through AccountPool) to observe message, media-chunk
|
|
818
|
+
and request progress. This lets a caller distinguish a slow download from a
|
|
819
|
+
silent read. Defaults, batch hashes and acknowledgments stay unchanged. See
|
|
820
|
+
[heartbeat and watchdog guidance](docs/batch_heartbeat.md) for callback isolation,
|
|
821
|
+
replay behavior and the SDK-wait limit.
|
|
822
|
+
|
|
823
|
+
### Optional photo trails
|
|
824
|
+
|
|
825
|
+
For a text or album followed by additional photo-only posts, explicitly enable
|
|
826
|
+
the conservative heuristic over your current original observations:
|
|
827
|
+
|
|
828
|
+
```python
|
|
829
|
+
from tgdata import PhotoTrailPolicy, fold_photo_trails
|
|
830
|
+
|
|
831
|
+
view = fold_photo_trails(current_posts, policy=PhotoTrailPolicy(enabled=True))
|
|
832
|
+
print(view.groups) # tuples of original post IDs, anchor first
|
|
833
|
+
print(view.folded_posts, view.folded_photos)
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
The default policy is disabled. Every original `TelegramPost` and caption remains
|
|
837
|
+
in `view.posts`. A later observation can withdraw an inferred association; replace
|
|
838
|
+
the earlier view for your chosen scope instead of appending final posts. Counts
|
|
839
|
+
describe the current view, not additional work on each retry.
|
|
840
|
+
|
|
841
|
+
The helper makes no source requests or acknowledgments. The optional SQLite
|
|
842
|
+
example reuses saved original records and refuses a range that cuts a known
|
|
843
|
+
album, preserving intervening context. See [photo-trail views](docs/photo_trails.md)
|
|
844
|
+
for policy, limits and restart/acknowledgment guidance, or run
|
|
845
|
+
`python examples/photo_trails.py --demo` without Telegram.
|
|
846
|
+
|
|
781
847
|
### Daily continuation across restarts
|
|
782
848
|
|
|
783
849
|
Use a progress store when tgdata should remember unfinished delivery and the last
|
|
@@ -72,7 +72,7 @@ from .utils import (
|
|
|
72
72
|
# Progress tracking
|
|
73
73
|
from .progress import ProgressTracker
|
|
74
74
|
|
|
75
|
-
__version__ = "0.1"
|
|
75
|
+
__version__ = "0.1.1"
|
|
76
76
|
|
|
77
77
|
__all__ = [
|
|
78
78
|
# Explicit account onboarding
|
|
@@ -182,3 +182,9 @@ __all__ = [
|
|
|
182
182
|
# Progress
|
|
183
183
|
"ProgressTracker"
|
|
184
184
|
]
|
|
185
|
+
|
|
186
|
+
from .album import TelegramPost, AlbumAssemblyError, album_post_ids, assemble_albums
|
|
187
|
+
__all__ += ["TelegramPost", "AlbumAssemblyError", "album_post_ids", "assemble_albums"]
|
|
188
|
+
|
|
189
|
+
from .photo_trails import PhotoTrailPolicy, PhotoTrailView, PhotoTrailError, fold_photo_trails
|
|
190
|
+
__all__ += ["PhotoTrailPolicy", "PhotoTrailView", "PhotoTrailError", "fold_photo_trails"]
|
|
@@ -16,6 +16,8 @@ from uuid import uuid4
|
|
|
16
16
|
import telethon
|
|
17
17
|
from telethon import errors as rpc_errors
|
|
18
18
|
|
|
19
|
+
from .batch_heartbeat import heartbeat_kwargs
|
|
20
|
+
|
|
19
21
|
from . import health
|
|
20
22
|
from .batch_engine import _integer as _batch_integer
|
|
21
23
|
from .batch_files import BatchStorageError, prepare_directory
|
|
@@ -468,15 +470,16 @@ class AccountPool(ProgressFacade):
|
|
|
468
470
|
raise local from None
|
|
469
471
|
|
|
470
472
|
async def _read(self, chat_id, *, after_id=0, limit=200, download_media_to=None,
|
|
471
|
-
start_date=None, end_date=None, _capture=None):
|
|
473
|
+
start_date=None, end_date=None, heartbeat=None, _capture=None):
|
|
472
474
|
"""Read once through the first successful eligible account, with provenance."""
|
|
475
|
+
progress = heartbeat_kwargs(heartbeat)
|
|
473
476
|
chat_id = self._group(chat_id)
|
|
474
477
|
after_id = _batch_integer(after_id, 'after_id', 0, MAX_MESSAGE_ID)
|
|
475
478
|
limit = _batch_integer(limit, 'limit', 1, MAX_BATCH_MESSAGES)
|
|
476
479
|
_HistoryWindow.from_dates(start_date, end_date)
|
|
477
480
|
root = prepare_directory(download_media_to) if download_media_to is not None else None
|
|
478
481
|
options = dict(after_id=after_id, limit=limit, download_media_to=root,
|
|
479
|
-
start_date=start_date, end_date=end_date)
|
|
482
|
+
start_date=start_date, end_date=end_date, **progress)
|
|
480
483
|
with self._operation():
|
|
481
484
|
raw, document = await self._load()
|
|
482
485
|
attempts, tried = [], set()
|
|
@@ -520,33 +523,33 @@ class AccountPool(ProgressFacade):
|
|
|
520
523
|
raise PoolUnavailable(statuses.accounts, attempts, retry_after) from None
|
|
521
524
|
|
|
522
525
|
async def read(self, chat_id, *, capture, after_id=0, limit=200,
|
|
523
|
-
download_media_to=None, start_date=None, end_date=None):
|
|
526
|
+
download_media_to=None, start_date=None, end_date=None, heartbeat=None):
|
|
524
527
|
"""Read with a caller-retained, single-use data destination."""
|
|
525
528
|
with _capture_call(capture) as writer:
|
|
526
529
|
result = await self._read(chat_id, after_id=after_id, limit=limit,
|
|
527
530
|
download_media_to=download_media_to, start_date=start_date,
|
|
528
|
-
end_date=end_date, _capture=writer)
|
|
531
|
+
end_date=end_date, _capture=writer, **heartbeat_kwargs(heartbeat))
|
|
529
532
|
writer.offer(result.batch)
|
|
530
533
|
return result
|
|
531
534
|
|
|
532
535
|
async def _read_batch(self, group_id, *, after_id=0, limit=200,
|
|
533
|
-
download_media_to=None, start_date=None, end_date=None, _capture=None):
|
|
536
|
+
download_media_to=None, start_date=None, end_date=None, heartbeat=None, _capture=None):
|
|
534
537
|
result = await self._read(group_id, after_id=after_id, limit=limit,
|
|
535
538
|
download_media_to=download_media_to, start_date=start_date,
|
|
536
|
-
end_date=end_date, _capture=_capture)
|
|
539
|
+
end_date=end_date, _capture=_capture, **heartbeat_kwargs(heartbeat))
|
|
537
540
|
return result.batch
|
|
538
541
|
|
|
539
542
|
async def get_message_batch(self, group_id, *, capture, after_id=0, limit=200,
|
|
540
|
-
download_media_to=None, start_date=None, end_date=None):
|
|
543
|
+
download_media_to=None, start_date=None, end_date=None, heartbeat=None):
|
|
541
544
|
"""Return a bounded batch; keep capture to inspect data after a stop."""
|
|
542
545
|
with _capture_call(capture) as writer:
|
|
543
546
|
batch = await self._read_batch(group_id, after_id=after_id, limit=limit,
|
|
544
547
|
download_media_to=download_media_to, start_date=start_date,
|
|
545
|
-
end_date=end_date, _capture=writer)
|
|
548
|
+
end_date=end_date, _capture=writer, **heartbeat_kwargs(heartbeat))
|
|
546
549
|
writer.offer(batch)
|
|
547
550
|
return batch
|
|
548
551
|
|
|
549
|
-
async def sync_group(self, chat_id, *, capture, limit=200, download_media_to=None):
|
|
552
|
+
async def sync_group(self, chat_id, *, capture, limit=200, download_media_to=None, heartbeat=None):
|
|
550
553
|
"""Prepare/replay daily delivery with a new caller-retained capture.
|
|
551
554
|
|
|
552
555
|
Only the saved pending batch's receipt can acknowledge progress. The
|
|
@@ -554,22 +557,23 @@ class AccountPool(ProgressFacade):
|
|
|
554
557
|
"""
|
|
555
558
|
with _capture_call(capture) as writer:
|
|
556
559
|
batch = await self.sync_engine.prepare(chat_id, limit=limit,
|
|
557
|
-
download_media_to=download_media_to, _capture=writer)
|
|
560
|
+
download_media_to=download_media_to, _capture=writer, **heartbeat_kwargs(heartbeat))
|
|
558
561
|
writer.offer(batch)
|
|
559
562
|
return batch
|
|
560
563
|
|
|
561
564
|
def _new_backfill_engine(self, collection):
|
|
562
565
|
return BackfillEngine(self._backfill_store, collection, read_batch=self._read_batch)
|
|
563
566
|
|
|
564
|
-
async def prepare_backfill(self, context, *, capture, download_media_to=None):
|
|
567
|
+
async def prepare_backfill(self, context, *, capture, download_media_to=None, heartbeat=None):
|
|
565
568
|
"""Prepare/replay one historical turn with a new retained capture.
|
|
566
569
|
|
|
567
570
|
Source closure and progress publication have separate lifetimes; this
|
|
568
571
|
call finalizes its capture only after its own preparation ends.
|
|
569
572
|
"""
|
|
570
573
|
with _capture_call(capture) as writer:
|
|
574
|
+
progress = heartbeat_kwargs(heartbeat)
|
|
571
575
|
engine, context = self._backfill_for(context, BackfillPrepareContext)
|
|
572
|
-
turn = await engine.prepare(context, download_media_to=download_media_to, _capture=writer)
|
|
576
|
+
turn = await engine.prepare(context, download_media_to=download_media_to, _capture=writer, **progress)
|
|
573
577
|
writer.offer(turn.batch)
|
|
574
578
|
return turn
|
|
575
579
|
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Pure, opt-in assembly of observed Telegram album members.
|
|
2
|
+
|
|
3
|
+
Posts describe observed data, never source receipts or globally complete albums.
|
|
4
|
+
Callers retain prior posts and commit them before acknowledging raw deliveries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
from .message_batch import (
|
|
13
|
+
MessageBatch, BatchFormatError, MIN_LONG, _canonical, _digest, _identifier,
|
|
14
|
+
_invalid_constant, _json_pairs, _keys, _validate_record,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_MAX_MEMBERS = 10000
|
|
19
|
+
DEFAULT_MAX_BYTES = 16 * 1024 * 1024
|
|
20
|
+
_KEYS = frozenset({'schema', 'version', 'post_id', 'revision', 'chat_id',
|
|
21
|
+
'grouped_id', 'media_mode', 'membership', 'members'})
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AlbumAssemblyError(ValueError):
|
|
25
|
+
"""Local format, conflict or resource refusal; never an account-health event."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.__suppress_context__ = True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _limits(max_members, max_bytes):
|
|
33
|
+
for value, name in ((max_members, 'max_members'), (max_bytes, 'max_bytes')):
|
|
34
|
+
if type(value) is not int or value <= 0:
|
|
35
|
+
raise AlbumAssemblyError('{} must be a positive integer'.format(name))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _bytes(text, maximum):
|
|
39
|
+
try:
|
|
40
|
+
size = len(text.encode('utf-8')) if isinstance(text, str) else len(text)
|
|
41
|
+
except UnicodeError:
|
|
42
|
+
raise AlbumAssemblyError('post data must be valid UTF-8') from None
|
|
43
|
+
if size > maximum:
|
|
44
|
+
raise AlbumAssemblyError('max_bytes exceeded')
|
|
45
|
+
return size
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _identity(chat_id, member):
|
|
49
|
+
grouped = member['grouped_id']
|
|
50
|
+
return 'tg:{}:{}:{}'.format(chat_id, 'album' if grouped is not None else 'message',
|
|
51
|
+
grouped if grouped is not None else member['id'])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _revision(value):
|
|
55
|
+
body = {key: item for key, item in value.items() if key != 'revision'}
|
|
56
|
+
return hashlib.sha256(_canonical(body).encode('utf-8')).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validate(value, max_members):
|
|
60
|
+
_keys(value, _KEYS, 'post')
|
|
61
|
+
if value['schema'] != 'tgdata.telegram-post' or type(value['version']) is not int or value['version'] != 1:
|
|
62
|
+
raise AlbumAssemblyError('unsupported post schema or version')
|
|
63
|
+
_identifier(value['chat_id'], 'post.chat_id', MIN_LONG, -1)
|
|
64
|
+
_identifier(value['grouped_id'], 'post.grouped_id', nullable=True)
|
|
65
|
+
_digest(value['revision'], 'post.revision')
|
|
66
|
+
if value['media_mode'] not in ('references', 'download'):
|
|
67
|
+
raise AlbumAssemblyError('unsupported post media_mode')
|
|
68
|
+
members = value['members']
|
|
69
|
+
if type(members) is not list or not members or len(members) > max_members:
|
|
70
|
+
raise AlbumAssemblyError('members must be nonempty and within max_members')
|
|
71
|
+
grouped = value['grouped_id'] is not None
|
|
72
|
+
if value['membership'] != ('unconfirmed' if grouped else 'single'):
|
|
73
|
+
raise AlbumAssemblyError('post membership must reflect observed album or singleton data')
|
|
74
|
+
if not grouped and len(members) != 1:
|
|
75
|
+
raise AlbumAssemblyError('an ungrouped post must have one member')
|
|
76
|
+
last = 0
|
|
77
|
+
for member in members:
|
|
78
|
+
ident = _validate_record(member, value['media_mode'])
|
|
79
|
+
if ident <= last:
|
|
80
|
+
raise AlbumAssemblyError('member IDs must increase strictly')
|
|
81
|
+
last = ident
|
|
82
|
+
if member['grouped_id'] != value['grouped_id'] or _identity(value['chat_id'], member) != value['post_id']:
|
|
83
|
+
raise AlbumAssemblyError('member does not match post identity')
|
|
84
|
+
if value['revision'] != _revision(value):
|
|
85
|
+
raise AlbumAssemblyError('post revision does not match content')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, init=False)
|
|
89
|
+
class TelegramPost:
|
|
90
|
+
"""Immutable observed post; getters return copies, albums stay unconfirmed.
|
|
91
|
+
|
|
92
|
+
The revision hashes canonical content, independently of raw batch partitions.
|
|
93
|
+
Loading validates the entire value. Limits can be raised explicitly; they
|
|
94
|
+
are a serialized-work policy, not a promise about total process memory.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
_encoded: str = field(repr=False)
|
|
98
|
+
|
|
99
|
+
def __init__(self, document, *, max_members=DEFAULT_MAX_MEMBERS, max_bytes=DEFAULT_MAX_BYTES):
|
|
100
|
+
_limits(max_members, max_bytes)
|
|
101
|
+
if type(document) is not dict:
|
|
102
|
+
raise AlbumAssemblyError('post must be a JSON object')
|
|
103
|
+
members = document.get('members')
|
|
104
|
+
if type(members) is list and len(members) > max_members:
|
|
105
|
+
raise AlbumAssemblyError('max_members exceeded')
|
|
106
|
+
try:
|
|
107
|
+
encoded = _canonical(document)
|
|
108
|
+
_bytes(encoded, max_bytes)
|
|
109
|
+
owned = json.loads(encoded)
|
|
110
|
+
_validate(owned, max_members)
|
|
111
|
+
except BatchFormatError as error:
|
|
112
|
+
raise AlbumAssemblyError(str(error)) from None
|
|
113
|
+
object.__setattr__(self, '_encoded', encoded)
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def from_json(cls, data, *, max_members=DEFAULT_MAX_MEMBERS, max_bytes=DEFAULT_MAX_BYTES):
|
|
117
|
+
_limits(max_members, max_bytes)
|
|
118
|
+
if type(data) not in (str, bytes):
|
|
119
|
+
raise AlbumAssemblyError('post JSON must be text or UTF-8 bytes')
|
|
120
|
+
_bytes(data, max_bytes)
|
|
121
|
+
try:
|
|
122
|
+
if isinstance(data, bytes):
|
|
123
|
+
data = data.decode('utf-8')
|
|
124
|
+
value = json.loads(data, object_pairs_hook=_json_pairs, parse_constant=_invalid_constant)
|
|
125
|
+
except (BatchFormatError, ValueError, UnicodeError, RecursionError):
|
|
126
|
+
raise AlbumAssemblyError('invalid post JSON') from None
|
|
127
|
+
return cls(value, max_members=max_members, max_bytes=max_bytes)
|
|
128
|
+
|
|
129
|
+
def to_json(self):
|
|
130
|
+
return self._encoded
|
|
131
|
+
|
|
132
|
+
def to_dict(self):
|
|
133
|
+
return json.loads(self._encoded)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def post_id(self):
|
|
137
|
+
return self.to_dict()['post_id']
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def revision(self):
|
|
141
|
+
return self.to_dict()['revision']
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def chat_id(self):
|
|
145
|
+
return int(self.to_dict()['chat_id'])
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def grouped_id(self):
|
|
149
|
+
value = self.to_dict()['grouped_id']
|
|
150
|
+
return int(value) if value is not None else None
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def media_mode(self):
|
|
154
|
+
return self.to_dict()['media_mode']
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def membership(self):
|
|
158
|
+
return self.to_dict()['membership']
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def members(self):
|
|
162
|
+
return self.to_dict()['members']
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def member_ids(self):
|
|
166
|
+
return tuple(int(member['id']) for member in self.to_dict()['members'])
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _batch(batch, max_bytes):
|
|
170
|
+
if type(batch) is not MessageBatch:
|
|
171
|
+
raise AlbumAssemblyError('batch must be a MessageBatch')
|
|
172
|
+
size = _bytes(batch.to_json(), max_bytes)
|
|
173
|
+
return batch.to_dict(), size
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def album_post_ids(batch, *, max_bytes=DEFAULT_MAX_BYTES):
|
|
177
|
+
"""Return affected post keys in first occurrence order, without any I/O."""
|
|
178
|
+
_limits(DEFAULT_MAX_MEMBERS, max_bytes)
|
|
179
|
+
value, _ = _batch(batch, max_bytes)
|
|
180
|
+
return tuple(dict.fromkeys(_identity(value['chat_id'], row) for row in value['messages']))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def assemble_albums(batch, *, previous, max_members=DEFAULT_MAX_MEMBERS, max_bytes=DEFAULT_MAX_BYTES):
|
|
184
|
+
"""Return updated affected posts from a raw batch and authoritative prior data.
|
|
185
|
+
|
|
186
|
+
``previous`` must map exactly every affected key to a TelegramPost or None.
|
|
187
|
+
None asserts successful lookup found no prior post; lookup errors must raise.
|
|
188
|
+
Load current prior values and save results in a caller-owned transaction/CAS.
|
|
189
|
+
Exact duplicates are idempotent; incompatible observations refuse entirely.
|
|
190
|
+
|
|
191
|
+
max_members bounds total returned members; max_bytes bounds the sum of raw
|
|
192
|
+
batch JSON, supplied post JSON and returned post JSON in UTF-8. No data is
|
|
193
|
+
silently evicted. This function never fetches, saves or acknowledges.
|
|
194
|
+
"""
|
|
195
|
+
_limits(max_members, max_bytes)
|
|
196
|
+
value, size = _batch(batch, max_bytes)
|
|
197
|
+
groups = {}
|
|
198
|
+
for member in value['messages']:
|
|
199
|
+
groups.setdefault(_identity(value['chat_id'], member), []).append(member)
|
|
200
|
+
if not isinstance(previous, Mapping) or len(previous) != len(groups) or set(previous) != set(groups):
|
|
201
|
+
raise AlbumAssemblyError('previous must contain exactly the affected post IDs')
|
|
202
|
+
|
|
203
|
+
# Check encoded input sizes before decoding prior observations. Revalidate
|
|
204
|
+
# each saved value with the caller's policy, including deliberately raised limits.
|
|
205
|
+
prior_documents = {}
|
|
206
|
+
for key in groups:
|
|
207
|
+
post = previous[key]
|
|
208
|
+
if post is None:
|
|
209
|
+
continue
|
|
210
|
+
if type(post) is not TelegramPost:
|
|
211
|
+
raise AlbumAssemblyError('previous values must be TelegramPost or None')
|
|
212
|
+
size += _bytes(post.to_json(), max_bytes)
|
|
213
|
+
if size > max_bytes:
|
|
214
|
+
raise AlbumAssemblyError('max_bytes exceeded')
|
|
215
|
+
loaded = TelegramPost.from_json(post.to_json(), max_members=max_members, max_bytes=max_bytes).to_dict()
|
|
216
|
+
if loaded['post_id'] != key or loaded['media_mode'] != value['media_mode']:
|
|
217
|
+
raise AlbumAssemblyError('prior post identity or media mode differs')
|
|
218
|
+
prior_documents[key] = loaded
|
|
219
|
+
|
|
220
|
+
output, count, seen_ids = [], 0, set()
|
|
221
|
+
for key, incoming in groups.items():
|
|
222
|
+
prior = prior_documents.get(key)
|
|
223
|
+
members = {row['id']: row for row in prior['members']} if prior is not None else {}
|
|
224
|
+
for row in incoming:
|
|
225
|
+
if row['id'] in members and members[row['id']] != row:
|
|
226
|
+
raise AlbumAssemblyError('conflicting observations of a member')
|
|
227
|
+
members[row['id']] = row
|
|
228
|
+
count += len(members)
|
|
229
|
+
if count > max_members:
|
|
230
|
+
raise AlbumAssemblyError('max_members exceeded')
|
|
231
|
+
if seen_ids.intersection(members):
|
|
232
|
+
raise AlbumAssemblyError('a member appears in conflicting posts')
|
|
233
|
+
seen_ids.update(members)
|
|
234
|
+
ordered = sorted(members.values(), key=lambda row: int(row['id']))
|
|
235
|
+
document = dict(schema='tgdata.telegram-post', version=1, post_id=key,
|
|
236
|
+
chat_id=value['chat_id'], grouped_id=incoming[0]['grouped_id'],
|
|
237
|
+
media_mode=value['media_mode'],
|
|
238
|
+
membership='unconfirmed' if incoming[0]['grouped_id'] is not None else 'single',
|
|
239
|
+
members=ordered)
|
|
240
|
+
document['revision'] = _revision(document)
|
|
241
|
+
post = TelegramPost(document, max_members=max_members, max_bytes=max_bytes)
|
|
242
|
+
size += _bytes(post.to_json(), max_bytes)
|
|
243
|
+
if size > max_bytes:
|
|
244
|
+
raise AlbumAssemblyError('max_bytes exceeded')
|
|
245
|
+
output.append((int(ordered[0]['id']), key, post))
|
|
246
|
+
return tuple(item[2] for item in sorted(output))
|
|
@@ -11,6 +11,8 @@ import re
|
|
|
11
11
|
import time
|
|
12
12
|
from uuid import uuid4
|
|
13
13
|
|
|
14
|
+
from .batch_heartbeat import heartbeat_kwargs
|
|
15
|
+
|
|
14
16
|
from .backfill import (
|
|
15
17
|
BackfillRunRef, BackfillStartRequest, BackfillStartResult, BackfillRecoveryResult,
|
|
16
18
|
BackfillControlResult,
|
|
@@ -384,13 +386,14 @@ class BackfillEngine:
|
|
|
384
386
|
return self._turn(state)
|
|
385
387
|
raise BackfillConflictError('source settlement kept conflicting; inspect the authoritative attempt')
|
|
386
388
|
|
|
387
|
-
async def prepare(self, context, *, download_media_to=None, _capture=None):
|
|
389
|
+
async def prepare(self, context, *, download_media_to=None, heartbeat=None, _capture=None):
|
|
388
390
|
"""Prepare/replay one exact delivery using current run/cursor/control intent.
|
|
389
391
|
|
|
390
392
|
Early pacing returns a wait without sleeping or reading. Pending replay
|
|
391
393
|
remains local, including while paused or cancelled. The
|
|
392
394
|
caller excludes other source readers in separate collections/daily jobs.
|
|
393
395
|
"""
|
|
396
|
+
progress = heartbeat_kwargs(heartbeat)
|
|
394
397
|
if not isinstance(context, BackfillPrepareContext):
|
|
395
398
|
raise BackfillConfigurationError('prepare requires BackfillPrepareContext')
|
|
396
399
|
context = BackfillPrepareContext.from_dict(context.to_dict())
|
|
@@ -430,7 +433,7 @@ class BackfillEngine:
|
|
|
430
433
|
options = dict(after_id=int(admitted['after_id']), limit=admitted['request']['batch_size'],
|
|
431
434
|
download_media_to=root,
|
|
432
435
|
start_date=_decode_date(admitted['window']['start_date']),
|
|
433
|
-
end_date=_decode_date(admitted['window']['end_date']))
|
|
436
|
+
end_date=_decode_date(admitted['window']['end_date']), **progress)
|
|
434
437
|
if _capture is not None:
|
|
435
438
|
options['_capture'] = _capture
|
|
436
439
|
try:
|
|
@@ -9,6 +9,7 @@ from telethon import utils as tl_utils
|
|
|
9
9
|
from telethon.tl import types
|
|
10
10
|
|
|
11
11
|
from .batch_files import download_blob, prepare_directory
|
|
12
|
+
from .batch_heartbeat import heartbeat_scope
|
|
12
13
|
from .message_batch import (
|
|
13
14
|
BatchFormatError, MessageBatch, MAX_BATCH_MESSAGES, MAX_MESSAGE_ID, MAX_LONG,
|
|
14
15
|
MIN_LONG, _identifier, _validate_record,
|
|
@@ -151,7 +152,15 @@ class BatchEngine:
|
|
|
151
152
|
self._retain_cancelled_prefix = _retain_cancelled_prefix
|
|
152
153
|
|
|
153
154
|
async def fetch_batch(self, group_id, *, after_id=0, limit=200, download_media_to=None,
|
|
154
|
-
start_date=None, end_date=None, _capture=None):
|
|
155
|
+
start_date=None, end_date=None, heartbeat=None, _capture=None):
|
|
156
|
+
with heartbeat_scope(heartbeat) as beat:
|
|
157
|
+
return await self._fetch_batch(group_id, after_id=after_id, limit=limit,
|
|
158
|
+
download_media_to=download_media_to, start_date=start_date,
|
|
159
|
+
end_date=end_date, _capture=_capture, _beat=beat)
|
|
160
|
+
|
|
161
|
+
async def _fetch_batch(self, group_id, *, after_id=0, limit=200, download_media_to=None,
|
|
162
|
+
start_date=None, end_date=None, _capture=None, _beat=None):
|
|
163
|
+
beat = _beat if _beat is not None else lambda phase: None
|
|
155
164
|
group_id = _group_argument(group_id)
|
|
156
165
|
after_id = _integer(after_id, 'after_id', 0, MAX_MESSAGE_ID)
|
|
157
166
|
limit = _integer(limit, 'limit', 1, MAX_BATCH_MESSAGES)
|
|
@@ -166,14 +175,18 @@ class BatchEngine:
|
|
|
166
175
|
records = []
|
|
167
176
|
scan_id = after_id
|
|
168
177
|
try:
|
|
178
|
+
beat("connect")
|
|
169
179
|
async with self.connection_engine.session() as client:
|
|
180
|
+
beat("connected")
|
|
181
|
+
beat("resolve")
|
|
170
182
|
if self._resolve_entity is not None:
|
|
171
183
|
entity = await self._resolve_entity(client, group_id)
|
|
172
184
|
else:
|
|
173
185
|
try:
|
|
174
186
|
entity = await client.get_entity(group_id)
|
|
175
187
|
except ValueError:
|
|
176
|
-
entity = await MessageEngine._entity_after_dialog_sync(client, group_id)
|
|
188
|
+
entity = await MessageEngine._entity_after_dialog_sync(client, group_id, beat)
|
|
189
|
+
beat("resolved")
|
|
177
190
|
if isinstance(entity, (types.ChatForbidden, types.ChannelForbidden)):
|
|
178
191
|
raise GroupAccessError('account has no access to the requested group')
|
|
179
192
|
if not isinstance(entity, (types.Chat, types.Channel)):
|
|
@@ -203,6 +216,7 @@ class BatchEngine:
|
|
|
203
216
|
raise BatchFormatError('message IDs did not advance after the cursor')
|
|
204
217
|
scan_id = ident
|
|
205
218
|
scanned += 1
|
|
219
|
+
beat("message scanned")
|
|
206
220
|
if window is not None:
|
|
207
221
|
date = _utc(message.date, 'message.date', allow_naive=True)
|
|
208
222
|
if date >= window.end_date:
|
|
@@ -215,11 +229,13 @@ class BatchEngine:
|
|
|
215
229
|
media = record['media']
|
|
216
230
|
if media_directory is not None and media is not None and media['downloadable']:
|
|
217
231
|
async def writer(output):
|
|
218
|
-
|
|
232
|
+
options = {} if _beat is None else {"progress_callback": lambda current, total: beat("media")}
|
|
233
|
+
return await client.download_media(message, file=output, **options)
|
|
219
234
|
|
|
220
235
|
media['blob'] = await download_blob(media_directory, writer, expected_size=media['size'])
|
|
221
236
|
_validate_record(record, mode)
|
|
222
237
|
records.append(record)
|
|
238
|
+
beat("messages")
|
|
223
239
|
if end_reached or scanned < remaining or scan_id == MAX_MESSAGE_ID:
|
|
224
240
|
break
|
|
225
241
|
# A full page of excluded lower-bound messages is not the
|