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.
Files changed (100) hide show
  1. {tgdata-0.1 → tgdata-0.1.1}/PKG-INFO +67 -1
  2. {tgdata-0.1 → tgdata-0.1.1}/README.md +66 -0
  3. {tgdata-0.1 → tgdata-0.1.1}/tgdata/__init__.py +7 -1
  4. {tgdata-0.1 → tgdata-0.1.1}/tgdata/account_pool.py +16 -12
  5. tgdata-0.1.1/tgdata/album.py +246 -0
  6. {tgdata-0.1 → tgdata-0.1.1}/tgdata/backfill_engine.py +5 -2
  7. {tgdata-0.1 → tgdata-0.1.1}/tgdata/batch_engine.py +19 -3
  8. tgdata-0.1.1/tgdata/batch_heartbeat.py +150 -0
  9. {tgdata-0.1 → tgdata-0.1.1}/tgdata/connection_engine.py +5 -4
  10. tgdata-0.1.1/tgdata/photo_trails.py +393 -0
  11. {tgdata-0.1 → tgdata-0.1.1}/tgdata/progress_facade.py +9 -4
  12. tgdata-0.1.1/tgdata/smoke_tests/test_42_album_assembly.py +951 -0
  13. tgdata-0.1.1/tgdata/smoke_tests/test_43_photo_trails.py +695 -0
  14. tgdata-0.1.1/tgdata/smoke_tests/test_44_batch_heartbeat.py +451 -0
  15. {tgdata-0.1 → tgdata-0.1.1}/tgdata/sync_engine.py +5 -2
  16. {tgdata-0.1 → tgdata-0.1.1}/tgdata/tgdata.py +7 -2
  17. {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/PKG-INFO +67 -1
  18. {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/SOURCES.txt +6 -0
  19. {tgdata-0.1 → tgdata-0.1.1}/setup.cfg +0 -0
  20. {tgdata-0.1 → tgdata-0.1.1}/setup.py +0 -0
  21. {tgdata-0.1 → tgdata-0.1.1}/tgdata/account_operation.py +0 -0
  22. {tgdata-0.1 → tgdata-0.1.1}/tgdata/backfill.py +0 -0
  23. {tgdata-0.1 → tgdata-0.1.1}/tgdata/backfill_state.py +0 -0
  24. {tgdata-0.1 → tgdata-0.1.1}/tgdata/batch_files.py +0 -0
  25. {tgdata-0.1 → tgdata-0.1.1}/tgdata/budget_client.py +0 -0
  26. {tgdata-0.1 → tgdata-0.1.1}/tgdata/discovery_engine.py +0 -0
  27. {tgdata-0.1 → tgdata-0.1.1}/tgdata/group_operations.py +0 -0
  28. {tgdata-0.1 → tgdata-0.1.1}/tgdata/health.py +0 -0
  29. {tgdata-0.1 → tgdata-0.1.1}/tgdata/history_window.py +0 -0
  30. {tgdata-0.1 → tgdata-0.1.1}/tgdata/join_budget.py +0 -0
  31. {tgdata-0.1 → tgdata-0.1.1}/tgdata/join_client.py +0 -0
  32. {tgdata-0.1 → tgdata-0.1.1}/tgdata/login.py +0 -0
  33. {tgdata-0.1 → tgdata-0.1.1}/tgdata/message_batch.py +0 -0
  34. {tgdata-0.1 → tgdata-0.1.1}/tgdata/message_engine.py +0 -0
  35. {tgdata-0.1 → tgdata-0.1.1}/tgdata/models.py +0 -0
  36. {tgdata-0.1 → tgdata-0.1.1}/tgdata/owned_health.py +0 -0
  37. {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_lifecycle.py +0 -0
  38. {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_observation.py +0 -0
  39. {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_result.py +0 -0
  40. {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_source.py +0 -0
  41. {tgdata-0.1 → tgdata-0.1.1}/tgdata/pool_state.py +0 -0
  42. {tgdata-0.1 → tgdata-0.1.1}/tgdata/progress.py +0 -0
  43. {tgdata-0.1 → tgdata-0.1.1}/tgdata/read_budget.py +0 -0
  44. {tgdata-0.1 → tgdata-0.1.1}/tgdata/session_store.py +0 -0
  45. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/__init__.py +0 -0
  46. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/my_test.py +0 -0
  47. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/pool_storage_cases.py +0 -0
  48. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_00_connection.py +0 -0
  49. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_01_list_group_chats.py +0 -0
  50. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_02_get_message_count.py +0 -0
  51. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_03_get_all_messages.py +0 -0
  52. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_04_get_all_messages_in_batches.py +0 -0
  53. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_05_with_progress_feature.py +0 -0
  54. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_06_polling_simple.py +0 -0
  55. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_07_on_new_message_simple.py +0 -0
  56. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_07_real_time_event_handler.py +0 -0
  57. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_08_batch_callback.py +0 -0
  58. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_08_polling.py +0 -0
  59. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_10_advanced_features.py +0 -0
  60. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_11_discover_groups.py +0 -0
  61. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_12_proxy.py +0 -0
  62. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_13_device_identity.py +0 -0
  63. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_14_flood_threshold.py +0 -0
  64. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_15_login_checks.py +0 -0
  65. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_16_health_events.py +0 -0
  66. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_17_session_store.py +0 -0
  67. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_18_read_budget.py +0 -0
  68. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_19_message_batches.py +0 -0
  69. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_22_daily_continuation.py +0 -0
  70. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_23_fixed_windows.py +0 -0
  71. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_24_backfill_state.py +0 -0
  72. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_25_backfill_delivery.py +0 -0
  73. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_26_backfill_completion.py +0 -0
  74. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_27_backfill_pacing.py +0 -0
  75. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_28_backfill_controls.py +0 -0
  76. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_29_backfill_public.py +0 -0
  77. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_30_backfill_integration.py +0 -0
  78. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_31_account_pool.py +0 -0
  79. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_32_account_operation.py +0 -0
  80. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_33_owned_health.py +0 -0
  81. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_34_group_access.py +0 -0
  82. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_35_join_budget.py +0 -0
  83. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_36_group_join.py +0 -0
  84. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_37_worker.py +0 -0
  85. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_38_worker_process.py +0 -0
  86. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_39_pool_lifecycle.py +0 -0
  87. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_40_pool_outcomes.py +0 -0
  88. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_41_stepwise_login.py +0 -0
  89. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_debug_min_id.py +0 -0
  90. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_polling_debug.py +0 -0
  91. {tgdata-0.1 → tgdata-0.1.1}/tgdata/smoke_tests/test_username_support.py +0 -0
  92. {tgdata-0.1 → tgdata-0.1.1}/tgdata/sync_store.py +0 -0
  93. {tgdata-0.1 → tgdata-0.1.1}/tgdata/utils.py +0 -0
  94. {tgdata-0.1 → tgdata-0.1.1}/tgdata/worker.py +0 -0
  95. {tgdata-0.1 → tgdata-0.1.1}/tgdata/worker_engine.py +0 -0
  96. {tgdata-0.1 → tgdata-0.1.1}/tgdata/worker_protocol.py +0 -0
  97. {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/dependency_links.txt +0 -0
  98. {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/entry_points.txt +0 -0
  99. {tgdata-0.1 → tgdata-0.1.1}/tgdata.egg-info/requires.txt +0 -0
  100. {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
- return await client.download_media(message, file=output)
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