ferogram 0.5.3__tar.gz → 0.5.3b2__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 (109) hide show
  1. {ferogram-0.5.3 → ferogram-0.5.3b2}/PKG-INFO +2 -2
  2. {ferogram-0.5.3 → ferogram-0.5.3b2}/README.md +1 -1
  3. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/client.py +147 -14
  4. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/proxy.py +82 -9
  5. {ferogram-0.5.3 → ferogram-0.5.3b2}/pyproject.toml +1 -1
  6. {ferogram-0.5.3 → ferogram-0.5.3b2}/src/connection.rs +137 -1
  7. {ferogram-0.5.3 → ferogram-0.5.3b2}/src/message_box.rs +43 -0
  8. {ferogram-0.5.3 → ferogram-0.5.3b2}/.github/workflows/compile-check.yml +0 -0
  9. {ferogram-0.5.3 → ferogram-0.5.3b2}/.github/workflows/publish.yml +0 -0
  10. {ferogram-0.5.3 → ferogram-0.5.3b2}/.gitignore +0 -0
  11. {ferogram-0.5.3 → ferogram-0.5.3b2}/.readthedocs.yaml +0 -0
  12. {ferogram-0.5.3 → ferogram-0.5.3b2}/CHANGELOG.md +0 -0
  13. {ferogram-0.5.3 → ferogram-0.5.3b2}/Cargo.lock +0 -0
  14. {ferogram-0.5.3 → ferogram-0.5.3b2}/Cargo.toml +0 -0
  15. {ferogram-0.5.3 → ferogram-0.5.3b2}/FEATURES.md +0 -0
  16. {ferogram-0.5.3 → ferogram-0.5.3b2}/LICENSE-APACHE +0 -0
  17. {ferogram-0.5.3 → ferogram-0.5.3b2}/LICENSE-MIT +0 -0
  18. {ferogram-0.5.3 → ferogram-0.5.3b2}/Makefile +0 -0
  19. {ferogram-0.5.3 → ferogram-0.5.3b2}/assets/architecture.svg +0 -0
  20. {ferogram-0.5.3 → ferogram-0.5.3b2}/build.rs +0 -0
  21. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/admin_tools.py +0 -0
  22. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/command_bot.py +0 -0
  23. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/echo_bot.py +0 -0
  24. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/group_management.py +0 -0
  25. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/media_bot.py +0 -0
  26. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/raw_invoke.py +0 -0
  27. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/rich_message.py +0 -0
  28. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/search_bot.py +0 -0
  29. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/send_hi.py +0 -0
  30. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/send_media.py +0 -0
  31. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/send_message.py +0 -0
  32. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/update_handlers.py +0 -0
  33. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/user_management.py +0 -0
  34. {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/userbot.py +0 -0
  35. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/__init__.py +0 -0
  36. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/api.tl +0 -0
  37. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/filters.py +0 -0
  38. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/keyboards.py +0 -0
  39. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/logging.py +0 -0
  40. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/py.typed +0 -0
  41. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/__init__.py +0 -0
  42. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/api/__init__.py +0 -0
  43. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/api/functions.py +0 -0
  44. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/api/types.py +0 -0
  45. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/codegen.py +0 -0
  46. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/__init__.py +0 -0
  47. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/_tl_schema.py +0 -0
  48. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/__init__.py +0 -0
  49. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/account.py +0 -0
  50. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/aicompose.py +0 -0
  51. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/auth.py +0 -0
  52. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/bots.py +0 -0
  53. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/channels.py +0 -0
  54. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/chatlists.py +0 -0
  55. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/communities.py +0 -0
  56. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/contacts.py +0 -0
  57. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/ephemeral.py +0 -0
  58. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/folders.py +0 -0
  59. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/fragment.py +0 -0
  60. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/help.py +0 -0
  61. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/langpack.py +0 -0
  62. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/messages.py +0 -0
  63. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/payments.py +0 -0
  64. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/phone.py +0 -0
  65. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/photos.py +0 -0
  66. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/premium.py +0 -0
  67. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/smsjobs.py +0 -0
  68. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/stats.py +0 -0
  69. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/stickers.py +0 -0
  70. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/stories.py +0 -0
  71. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/updates.py +0 -0
  72. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/upload.py +0 -0
  73. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/users.py +0 -0
  74. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/__init__.py +0 -0
  75. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/_base.py +0 -0
  76. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/account.py +0 -0
  77. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/aicompose.py +0 -0
  78. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/auth.py +0 -0
  79. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/bots.py +0 -0
  80. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/channels.py +0 -0
  81. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/chatlists.py +0 -0
  82. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/communities.py +0 -0
  83. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/contacts.py +0 -0
  84. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/fragment.py +0 -0
  85. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/help.py +0 -0
  86. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/messages.py +0 -0
  87. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/payments.py +0 -0
  88. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/phone.py +0 -0
  89. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/photos.py +0 -0
  90. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/premium.py +0 -0
  91. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/smsjobs.py +0 -0
  92. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/stats.py +0 -0
  93. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/stickers.py +0 -0
  94. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/storage.py +0 -0
  95. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/stories.py +0 -0
  96. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/updates.py +0 -0
  97. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/upload.py +0 -0
  98. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/users.py +0 -0
  99. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/tl.py +0 -0
  100. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/rich.py +0 -0
  101. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/types.py +0 -0
  102. {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/updates.py +0 -0
  103. {ferogram-0.5.3 → ferogram-0.5.3b2}/src/lib.rs +0 -0
  104. {ferogram-0.5.3 → ferogram-0.5.3b2}/src/pipelined.rs +0 -0
  105. {ferogram-0.5.3 → ferogram-0.5.3b2}/src/raw.rs +0 -0
  106. {ferogram-0.5.3 → ferogram-0.5.3b2}/src/session.rs +0 -0
  107. {ferogram-0.5.3 → ferogram-0.5.3b2}/src/srp.rs +0 -0
  108. {ferogram-0.5.3 → ferogram-0.5.3b2}/tests/test_rich.py +0 -0
  109. {ferogram-0.5.3 → ferogram-0.5.3b2}/tests/test_tl_roundtrip.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferogram
3
- Version: 0.5.3
3
+ Version: 0.5.3b2
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -32,7 +32,7 @@ Project-URL: Tracker, https://github.com/ankit-chaubey/ferogram-py/issues
32
32
  ![License](https://img.shields.io/badge/License-MIT%20%7C%20Apache--2.0-64748B?style=flat-square)
33
33
  ![Telegram](https://img.shields.io/badge/Telegram-FerogramChat-06B6D4?style=flat-square&logo=telegram&logoColor=white)
34
34
 
35
- **A modern, elegant, asynchronous MTProto framework for building Telegram user clients and bots in Python.**
35
+ **A modern, elegant, asynchronous MTProto framework for building Telegram clients and bots in Python.**
36
36
 
37
37
  </div>
38
38
 
@@ -8,7 +8,7 @@
8
8
  ![License](https://img.shields.io/badge/License-MIT%20%7C%20Apache--2.0-64748B?style=flat-square)
9
9
  ![Telegram](https://img.shields.io/badge/Telegram-FerogramChat-06B6D4?style=flat-square&logo=telegram&logoColor=white)
10
10
 
11
- **A modern, elegant, asynchronous MTProto framework for building Telegram user clients and bots in Python.**
11
+ **A modern, elegant, asynchronous MTProto framework for building Telegram clients and bots in Python.**
12
12
 
13
13
  </div>
14
14
 
@@ -631,6 +631,12 @@ class Client(_RichMixin):
631
631
  self._transfer_semaphore = asyncio.Semaphore(self._transfer_limits.max_tcp_connections)
632
632
  self._conn: DcConnection | None = None
633
633
  self._frame_pump_task: asyncio.Task | None = None
634
+ # Periodic flush of the peer access-hash cache to the session file.
635
+ # Mirrors the Rust core's dirty-flag + 60s interval snapshot saver:
636
+ # the cache changes far more often than it's worth hitting disk for,
637
+ # so writes are batched instead of happening on every new peer.
638
+ self._peer_cache_save_task: asyncio.Task | None = None
639
+ self._peer_cache_save_interval = 60
634
640
  self._dc_id: int = 0
635
641
  # DCs we've already bound a worker connection's auth key to via
636
642
  # auth.importAuthorization this process run (export tokens are
@@ -950,22 +956,30 @@ class Client(_RichMixin):
950
956
  for _ in range(self._workers)
951
957
  ]
952
958
  try:
953
- # Seed pts/qts/date/seq. MessageBox.set_state is only valid
954
- # while empty, so this only ever runs once per MessageBox
955
- # lifetime (i.e. not again after a DC migration - the box
956
- # already has state by then).
959
+ # Seed pts/qts/date/seq. seed_state() re-checks emptiness and
960
+ # writes atomically in one lock acquisition on the Rust side, so
961
+ # a real update landing during the getState RPC below (this box
962
+ # is bound to the live connection) can't race the write and
963
+ # trip MessageBox's is-empty invariant - it just means seeding
964
+ # is skipped because the live update already got there first.
957
965
  if await self._message_box.is_empty():
958
966
  try:
959
967
  state = await self._rpc({"_": "updates.getState"})
960
- await self._message_box.set_state(
961
- state.get("pts", 0), state.get("qts", 0),
962
- state.get("date", 0), state.get("seq", 0),
963
- )
964
- self._log.debug(
965
- "seeded state: pts=%s qts=%s date=%s seq=%s",
968
+ seeded = await self._message_box.seed_state(
966
969
  state.get("pts", 0), state.get("qts", 0),
967
970
  state.get("date", 0), state.get("seq", 0),
968
971
  )
972
+ if seeded:
973
+ self._log.debug(
974
+ "seeded state: pts=%s qts=%s date=%s seq=%s",
975
+ state.get("pts", 0), state.get("qts", 0),
976
+ state.get("date", 0), state.get("seq", 0),
977
+ )
978
+ else:
979
+ self._log.debug(
980
+ "getState returned but message_box was already "
981
+ "seeded by a live update in the meantime; skipped"
982
+ )
969
983
  except Exception as e:
970
984
  self._log.warning("getState failed: %s", e)
971
985
 
@@ -1372,6 +1386,98 @@ class Client(_RichMixin):
1372
1386
  for ch in obj.get("chats") or []:
1373
1387
  self._peer_cache.store_chat_entity(ch)
1374
1388
 
1389
+ async def _load_persisted_update_state(self) -> None:
1390
+ """If `catch_up=True`, restore `MessageBox` from whatever
1391
+ pts/qts/date/seq state was persisted last run, so `getDifference`
1392
+ resumes from the pre-shutdown position instead of jumping straight
1393
+ to the current server state and silently skipping whatever came in
1394
+ while disconnected. No-op when `catch_up` is off, or when nothing
1395
+ has been saved yet (first run, fresh session) - the existing
1396
+ `MessageBox()` in `__init__` is left as-is and gets seeded from a
1397
+ fresh `updates.getState` at the start of `_run_updates()`, same as
1398
+ before."""
1399
+ if not self.catch_up:
1400
+ return
1401
+ conn = self._require_conn()
1402
+ pts, qts, date, seq, channels = await conn.get_persisted_update_state()
1403
+ if pts <= 0:
1404
+ self._log.debug(
1405
+ "catch_up requested but no saved update state; starting fresh"
1406
+ )
1407
+ return
1408
+ self._message_box = MessageBox.load(pts, qts, date, seq, channels)
1409
+ self._log.debug(
1410
+ "update state restored: pts=%d, qts=%d, seq=%d, %d channels tracked",
1411
+ pts, qts, seq, len(channels),
1412
+ )
1413
+
1414
+ async def _flush_update_state(self) -> None:
1415
+ """Push the current pts/qts/date/seq snapshot into the Rust
1416
+ binding's in-memory session record and persist it, so a future
1417
+ `catch_up=True` run has something real to load. Cheap enough to
1418
+ call unconditionally on every autosave tick and on shutdown -
1419
+ unlike the peer cache there's no dirty flag, since pts advances on
1420
+ essentially every update."""
1421
+ conn = self._conn
1422
+ if conn is None:
1423
+ return
1424
+ pts, qts, date, seq, channels = await self._message_box.session_state()
1425
+ if pts <= 0:
1426
+ return
1427
+ await conn.sync_persisted_update_state(pts, qts, date, seq, channels)
1428
+ await conn.save_session()
1429
+
1430
+ async def _load_persisted_peer_cache(self) -> None:
1431
+ """Seed `self._peer_cache` from whatever the session file already
1432
+ has, right after connect() and before the first RPC. Without this,
1433
+ every peer has to be re-resolved from scratch each run even though
1434
+ the Rust binding already persisted them - see
1435
+ `DcConnection.get_persisted_peers()`."""
1436
+ conn = self._require_conn()
1437
+ peers, min_peers = await conn.get_persisted_peers()
1438
+ self._peer_cache.load_persisted(peers, min_peers)
1439
+ cache = self._peer_cache
1440
+ self._log.debug(
1441
+ "peer cache restored: %d users, %d channels, %d communities, "
1442
+ "%d chats, %d channels_min, %d min-peer contexts",
1443
+ len(cache._users),
1444
+ len(cache._channels),
1445
+ len(cache._communities),
1446
+ len(cache._chats),
1447
+ len(cache.channels_min),
1448
+ len(cache.min_contexts),
1449
+ )
1450
+
1451
+ async def _flush_peer_cache(self) -> None:
1452
+ """Push the current peer cache into the Rust binding's in-memory
1453
+ snapshot and persist it. Safe to call even when nothing changed -
1454
+ callers should check `self._peer_cache.dirty` first to avoid
1455
+ needless disk writes on the autosave interval."""
1456
+ conn = self._conn
1457
+ if conn is None:
1458
+ return
1459
+ peers, min_peers = self._peer_cache.to_persisted()
1460
+ await conn.sync_persisted_peers(peers, min_peers)
1461
+ await conn.save_session()
1462
+ self._peer_cache.dirty = False
1463
+
1464
+ async def _peer_cache_autosave_loop(self) -> None:
1465
+ try:
1466
+ while True:
1467
+ await asyncio.sleep(self._peer_cache_save_interval)
1468
+ if self._peer_cache.dirty:
1469
+ try:
1470
+ await self._flush_peer_cache()
1471
+ except Exception as exc:
1472
+ self._log.warning("peer cache autosave failed: %s", exc)
1473
+ try:
1474
+ await self._flush_update_state()
1475
+ except Exception as exc:
1476
+ self._log.warning("update state autosave failed: %s", exc)
1477
+ except asyncio.CancelledError:
1478
+ pass
1479
+
1480
+
1375
1481
 
1376
1482
  async def start(
1377
1483
  self,
@@ -1422,7 +1528,10 @@ class Client(_RichMixin):
1422
1528
  self._dc_id = self._conn.dc_id
1423
1529
  self._log.extra["client"] = f"{_session_tag(self.session, self.session_string)} dc{self._dc_id}"
1424
1530
  self._log.info("connected to DC%d in %dms", self._dc_id, connect_ms)
1531
+ await self._load_persisted_update_state()
1425
1532
  self._conn.bind_message_box(self._message_box)
1533
+ await self._load_persisted_peer_cache()
1534
+ self._peer_cache_save_task = asyncio.create_task(self._peer_cache_autosave_loop())
1426
1535
  if self._future_auth_token is not None:
1427
1536
  # Don't clobber a real token the session already persisted
1428
1537
  # from a previous sign_out() with a stale constructor value.
@@ -1485,10 +1594,26 @@ class Client(_RichMixin):
1485
1594
  print("\nPlease ensure your usage complies with Telegram's API Terms of Service.")
1486
1595
 
1487
1596
  async def stop(self) -> None:
1597
+ """Disconnect and persist final state. Does NOT log the account out
1598
+ of Telegram - the auth key and session stay valid for next start().
1599
+ Call `sign_out()` yourself first if you actually want to deauthorize."""
1488
1600
  if self._conn:
1489
- await self.sign_out()
1601
+ if self._peer_cache_save_task is not None:
1602
+ self._peer_cache_save_task.cancel()
1603
+ with suppress(asyncio.CancelledError):
1604
+ await self._peer_cache_save_task
1605
+ self._peer_cache_save_task = None
1606
+ with suppress(Exception):
1607
+ # Final unconditional flush so nothing learned since the
1608
+ # last autosave tick is lost - mirrors the Rust core's
1609
+ # save-on-shutdown in its own snapshot saver task.
1610
+ await self._flush_peer_cache()
1611
+ with suppress(Exception):
1612
+ await self._flush_update_state()
1613
+ with suppress(Exception):
1614
+ await self._conn.save_session()
1490
1615
  self._conn = None
1491
- self._log.info("signed out")
1616
+ self._log.info("disconnected")
1492
1617
 
1493
1618
  async def run_until_disconnected(self) -> None:
1494
1619
  await self.start()
@@ -1502,6 +1627,15 @@ class Client(_RichMixin):
1502
1627
  self._frame_pump_task.cancel()
1503
1628
  with suppress(asyncio.CancelledError):
1504
1629
  await self._frame_pump_task
1630
+ if self._peer_cache_save_task is not None:
1631
+ self._peer_cache_save_task.cancel()
1632
+ with suppress(asyncio.CancelledError):
1633
+ await self._peer_cache_save_task
1634
+ self._peer_cache_save_task = None
1635
+ with suppress(Exception):
1636
+ await self._flush_peer_cache()
1637
+ with suppress(Exception):
1638
+ await self._flush_update_state()
1505
1639
 
1506
1640
  def run(self) -> None:
1507
1641
  try:
@@ -1513,8 +1647,7 @@ class Client(_RichMixin):
1513
1647
  return await self.start()
1514
1648
 
1515
1649
  async def __aexit__(self, *_: Any) -> None:
1516
- if self._conn:
1517
- self._conn = None
1650
+ await self.stop()
1518
1651
 
1519
1652
 
1520
1653
  async def is_authorized(self) -> bool:
@@ -176,28 +176,57 @@ class PeerCache:
176
176
  self._channels: dict[int, int] = {} # channel_id → access_hash
177
177
  self._communities: dict[int, int] = {} # community_id → access_hash
178
178
  self._chats: set[int] = set() # basic group ids (no hash)
179
+ # Channels seen with min=true: real channels, no usable access_hash
180
+ # yet. Never put these in _chats - that would make them resolve as
181
+ # a basic InputPeerChat, which is a fatal RPC failure. Mirrors
182
+ # ferogram-rust's `channels_min`.
183
+ self.channels_min: set[int] = set()
184
+ # user_id -> (peer_id, msg_id) for min users seen in a message
185
+ # context, addressable only via InputPeerUserFromMessage. Python
186
+ # doesn't populate this itself yet (that needs per-message context
187
+ # threading through _populate_cache, which nothing calls with today),
188
+ # but it round-trips whatever a Rust-core session already saved here
189
+ # instead of silently dropping it on the next save.
190
+ self.min_contexts: dict[int, tuple[int, int]] = {}
191
+ # Set whenever a store_* call actually changes something, so the
192
+ # client knows a disk flush is worth doing instead of writing an
193
+ # unchanged snapshot every autosave tick.
194
+ self.dirty = False
179
195
 
180
196
  def store_user(self, user_id: int, access_hash: int) -> None:
181
197
  self._store_hash(self._users, user_id, access_hash)
182
198
 
183
199
  def store_channel(self, channel_id: int, access_hash: int) -> None:
184
200
  self._store_hash(self._channels, channel_id, access_hash)
201
+ self.channels_min.discard(channel_id)
185
202
 
186
203
  def store_community(self, community_id: int, access_hash: int) -> None:
187
204
  self._store_hash(self._communities, community_id, access_hash)
188
205
 
189
- @staticmethod
190
- def _store_hash(bucket: dict[int, int], key: int, access_hash: int) -> None:
206
+ def store_channel_min(self, channel_id: int) -> None:
207
+ """Track a channel seen with min=true. No access_hash yet - just
208
+ existence, so `_resolve_int_peer` doesn't have to guess whether an
209
+ uncached id is a channel worth fetching or something else."""
210
+ if channel_id not in self._channels and channel_id not in self.channels_min:
211
+ self.channels_min.add(channel_id)
212
+ self.dirty = True
213
+
214
+ def _store_hash(self, bucket: dict[int, int], key: int, access_hash: int) -> None:
191
215
  # Never overwrite a valid non-zero hash with zero - a zero hash from
192
216
  # a later, less-detailed response shouldn't clobber one we already
193
217
  # resolved. Same rule as ferogram-rust's cache_user/cache_chat.
194
218
  if access_hash != 0:
195
- bucket[key] = access_hash
196
- else:
197
- bucket.setdefault(key, 0)
219
+ if bucket.get(key) != access_hash:
220
+ bucket[key] = access_hash
221
+ self.dirty = True
222
+ elif key not in bucket:
223
+ bucket[key] = 0
224
+ self.dirty = True
198
225
 
199
226
  def store_chat(self, chat_id: int) -> None:
200
- self._chats.add(chat_id)
227
+ if chat_id not in self._chats:
228
+ self._chats.add(chat_id)
229
+ self.dirty = True
201
230
 
202
231
  def store_chat_entity(self, chat: dict) -> None:
203
232
  """Route a raw `Chat` dict into the right bucket by its `_` type.
@@ -205,15 +234,20 @@ class PeerCache:
205
234
  Covers `channel`/`channelForbidden`, `community`/`communityForbidden`
206
235
  (both keyed by id + access_hash, addressed like a channel on the
207
236
  wire), and basic `chat`/`chatForbidden` (existence only, no hash).
208
- `min` entities are skipped - same as the rest of the cache, there is
209
- no separate min-tracking bucket here.
237
+ A min channel is tracked in `channels_min` (existence only, mirrors
238
+ ferogram-rust); a min community or anything else with `min` is
239
+ skipped, same as before.
210
240
  """
211
- if not isinstance(chat, dict) or chat.get("min"):
241
+ if not isinstance(chat, dict):
212
242
  return
213
243
  t = chat.get("_", "")
214
244
  cid = chat.get("id")
215
245
  if cid is None:
216
246
  return
247
+ if chat.get("min"):
248
+ if t in _CHANNEL_TYPES:
249
+ self.store_channel_min(cid)
250
+ return
217
251
  if t in _COMMUNITY_TYPES:
218
252
  ah = chat.get("access_hash")
219
253
  if ah is not None:
@@ -227,6 +261,45 @@ class PeerCache:
227
261
  # anything else (e.g. chatEmpty) is a no-op - mirrors the catch-all
228
262
  # arm in ferogram-rust's PeerCache::cache_chat
229
263
 
264
+ def to_persisted(self) -> tuple[list[tuple], list[tuple]]:
265
+ """Flatten this cache into the tuple shapes `DcConnection.
266
+ sync_persisted_peers()` expects: `(peers, min_peers)`. `channel_kind`
267
+ is always `None` here - Python doesn't track it yet, so a channel
268
+ saved by ferogram-py round-trips without one even if the Rust core
269
+ had recorded it previously."""
270
+ peers: list[tuple] = []
271
+ for uid, ah in self._users.items():
272
+ peers.append((uid, ah, False, False, None, False))
273
+ for cid, ah in self._channels.items():
274
+ peers.append((cid, ah, True, False, None, False))
275
+ for cid in self.channels_min:
276
+ peers.append((cid, 0, True, False, None, False))
277
+ for cid in self._chats:
278
+ peers.append((cid, 0, False, True, None, False))
279
+ for cid, ah in self._communities.items():
280
+ peers.append((cid, ah, False, False, None, True))
281
+ min_peers = [(uid, peer_id, msg_id) for uid, (peer_id, msg_id) in self.min_contexts.items()]
282
+ return peers, min_peers
283
+
284
+ def load_persisted(self, peers: list[tuple], min_peers: list[tuple]) -> None:
285
+ """Seed this cache from `DcConnection.get_persisted_peers()` output,
286
+ called once right after connect() so peers resolved in a previous
287
+ run don't need re-resolving. Does not touch `dirty`."""
288
+ for id_, access_hash, is_channel, is_chat, _kind, is_community in peers:
289
+ if is_chat:
290
+ self._chats.add(id_)
291
+ elif is_community:
292
+ self._communities[id_] = access_hash
293
+ elif is_channel:
294
+ if access_hash:
295
+ self._channels[id_] = access_hash
296
+ else:
297
+ self.channels_min.add(id_)
298
+ else:
299
+ self._users[id_] = access_hash
300
+ for uid, peer_id, msg_id in min_peers:
301
+ self.min_contexts[uid] = (peer_id, msg_id)
302
+
230
303
  def get_user(self, user_id: int) -> int | None:
231
304
  return self._users.get(user_id)
232
305
 
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ferogram"
7
- version = "0.5.3"
7
+ version = "0.5.3b2"
8
8
  description = "A modern, elegant, asynchronous MTProto framework for building Telegram clients and bots in Python."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT OR Apache-2.0" }
@@ -16,7 +16,10 @@ use ferogram_msgbox::MessageBoxes;
16
16
  use ferogram_mtsender::{
17
17
  AutoSleep, DcConnection as RustConn, FrameEvent, RetryLoop, RpcEnqueue, spawn_sender_task,
18
18
  };
19
- use ferogram_session::{DcEntry, PersistedSession, SessionBackend, default_dc_addresses};
19
+ use ferogram_session::{
20
+ CachedMinPeer, CachedPeer, ChannelKind, DcEntry, PersistedSession, SessionBackend,
21
+ UpdatesStateSnap, default_dc_addresses,
22
+ };
20
23
  use ferogram_tl_types::Deserializable;
21
24
  use pyo3::prelude::*;
22
25
  use pyo3_async_runtimes::tokio::future_into_py;
@@ -735,6 +738,139 @@ impl DcConnection {
735
738
  })
736
739
  }
737
740
 
741
+ /// Persisted update-sequence state (pts/qts/date/seq/channels), if any.
742
+ ///
743
+ /// Same tuple shape as `MessageBox.session_state()` / `MessageBox.load()`,
744
+ /// so callers round-trip directly:
745
+ ///
746
+ /// pts, qts, date, seq, channels = await conn.get_persisted_update_state()
747
+ /// msg_box = MessageBox.load(pts, qts, date, seq, channels) if pts > 0 else MessageBox()
748
+ ///
749
+ /// `pts == 0` means no update state has ever been saved for this session.
750
+ #[allow(clippy::type_complexity)]
751
+ fn get_persisted_update_state<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
752
+ let persisted = Arc::clone(&self.persisted);
753
+ future_into_py(py, async move {
754
+ let p = persisted.lock().await;
755
+ let us = &p.updates_state;
756
+ let channels: Vec<(i64, i32)> = us.channels.clone();
757
+ Ok((us.pts, us.qts, us.date, us.seq, channels))
758
+ })
759
+ }
760
+
761
+ /// Store an update-sequence snapshot (from `MessageBox.session_state()`)
762
+ /// into the in-memory session record. Updates memory only - pair with
763
+ /// `save_session()` to persist to disk, same pattern as
764
+ /// `sync_persisted_peers()`.
765
+ fn sync_persisted_update_state<'py>(
766
+ &self,
767
+ py: Python<'py>,
768
+ pts: i32,
769
+ qts: i32,
770
+ date: i32,
771
+ seq: i32,
772
+ channels: Vec<(i64, i32)>,
773
+ ) -> PyResult<Bound<'py, PyAny>> {
774
+ let persisted = Arc::clone(&self.persisted);
775
+ future_into_py(py, async move {
776
+ let mut p = persisted.lock().await;
777
+ p.updates_state = UpdatesStateSnap {
778
+ pts,
779
+ qts,
780
+ date,
781
+ seq,
782
+ channels,
783
+ };
784
+ Ok(())
785
+ })
786
+ }
787
+
788
+ /// Peer access-hash cache loaded from the session file, if any.
789
+ ///
790
+ /// Returns `(peers, min_peers)`:
791
+ /// - `peers`: `(id, access_hash, is_channel, is_chat, channel_kind, is_community)`
792
+ /// tuples. `channel_kind` is `0`=broadcast, `1`=megagroup, `2`=gigagroup,
793
+ /// or `None` for users, chats, communities, and channels saved before
794
+ /// kind tracking existed.
795
+ /// - `min_peers`: `(user_id, peer_id, msg_id)` tuples - users seen with
796
+ /// `min=true`, addressable only via `InputPeerUserFromMessage` using
797
+ /// the peer and message where they appeared.
798
+ ///
799
+ /// Call this once after `connect()` to seed the Python-side peer cache
800
+ /// so previously-resolved peers survive a restart instead of needing
801
+ /// re-resolution on first use.
802
+ #[allow(clippy::type_complexity)]
803
+ fn get_persisted_peers<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
804
+ let persisted = Arc::clone(&self.persisted);
805
+ future_into_py(py, async move {
806
+ let p = persisted.lock().await;
807
+ let peers: Vec<(i64, i64, bool, bool, Option<u8>, bool)> = p
808
+ .peers
809
+ .iter()
810
+ .map(|c| {
811
+ (
812
+ c.id,
813
+ c.access_hash,
814
+ c.is_channel,
815
+ c.is_chat,
816
+ c.channel_kind.map(ChannelKind::to_byte),
817
+ c.is_community,
818
+ )
819
+ })
820
+ .collect();
821
+ let min_peers: Vec<(i64, i64, i32)> = p
822
+ .min_peers
823
+ .iter()
824
+ .map(|m| (m.user_id, m.peer_id, m.msg_id))
825
+ .collect();
826
+ Ok((peers, min_peers))
827
+ })
828
+ }
829
+
830
+ /// Replace the in-memory peer cache snapshot from Python's `PeerCache`.
831
+ ///
832
+ /// Same tuple shapes as [`Self::get_persisted_peers`]. Updates memory
833
+ /// only - does not touch disk. Pair with periodic or shutdown-time
834
+ /// `save_session()` calls, mirroring the Rust core's dirty-flag +
835
+ /// interval flush (see `Client`'s full-session snapshot saver): the
836
+ /// peer cache changes far more often than it's worth writing to disk,
837
+ /// so batching the writes avoids hammering the session backend on
838
+ /// every single new peer.
839
+ #[allow(clippy::type_complexity)]
840
+ fn sync_persisted_peers<'py>(
841
+ &self,
842
+ py: Python<'py>,
843
+ peers: Vec<(i64, i64, bool, bool, Option<u8>, bool)>,
844
+ min_peers: Vec<(i64, i64, i32)>,
845
+ ) -> PyResult<Bound<'py, PyAny>> {
846
+ let persisted = Arc::clone(&self.persisted);
847
+ future_into_py(py, async move {
848
+ let mut p = persisted.lock().await;
849
+ p.peers = peers
850
+ .into_iter()
851
+ .map(
852
+ |(id, access_hash, is_channel, is_chat, kind_byte, is_community)| CachedPeer {
853
+ id,
854
+ access_hash,
855
+ is_channel,
856
+ is_chat,
857
+ channel_kind: kind_byte.and_then(ChannelKind::from_byte),
858
+ is_community,
859
+ },
860
+ )
861
+ .collect();
862
+ p.min_peers = min_peers
863
+ .into_iter()
864
+ .map(|(user_id, peer_id, msg_id)| CachedMinPeer {
865
+ user_id,
866
+ peer_id,
867
+ msg_id,
868
+ })
869
+ .collect();
870
+ Ok(())
871
+ })
872
+ }
873
+
738
874
  /// Streaming mode only (`connect(stream_updates=True)`): await the next
739
875
  /// event from the sender task's background read loop.
740
876
  ///
@@ -118,6 +118,49 @@ impl PyMessageBox {
118
118
  })
119
119
  }
120
120
 
121
+ /// Atomic `is_empty() -> set_state()`, in a single lock acquisition.
122
+ ///
123
+ /// The caller has to `await` an `updates.getState` RPC (tens to
124
+ /// hundreds of ms) between deciding to seed and having a state to seed
125
+ /// with. If this box is bound to a live connection (`bind_message_box`),
126
+ /// a real pushed update can land and populate it during that RPC. Doing
127
+ /// the plain `is_empty()` check before the RPC and `set_state()` after
128
+ /// leaves a window where that race turns into `set_state`'s
129
+ /// `debug_assert!(self.is_empty())` panicking on a box something else
130
+ /// just populated.
131
+ ///
132
+ /// This re-checks emptiness at the moment of the write, atomically, and
133
+ /// simply skips the write if something else got there first - exactly
134
+ /// what the Rust core's `sync_pts_state()` does by holding its
135
+ /// `tokio::Mutex` guard across both the check and the write. Returns
136
+ /// `true` if it actually seeded the state, `false` if it was skipped
137
+ /// because the box was no longer empty.
138
+ fn seed_state<'py>(
139
+ &self,
140
+ py: Python<'py>,
141
+ pts: i32,
142
+ qts: i32,
143
+ date: i32,
144
+ seq: i32,
145
+ ) -> PyResult<Bound<'py, PyAny>> {
146
+ let inner = Arc::clone(&self.inner);
147
+ future_into_py(py, async move {
148
+ let mut mb = inner.lock().await;
149
+ if mb.is_empty() {
150
+ mb.set_state(tl::types::updates::State {
151
+ pts,
152
+ qts,
153
+ date,
154
+ seq,
155
+ unread_count: 0,
156
+ });
157
+ Ok(true)
158
+ } else {
159
+ Ok(false)
160
+ }
161
+ })
162
+ }
163
+
121
164
  /// Record a channel's pts from `GetDialogs` - a no-op if already tracked.
122
165
  fn try_set_channel_state<'py>(
123
166
  &self,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes