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.
- {ferogram-0.5.3 → ferogram-0.5.3b2}/PKG-INFO +2 -2
- {ferogram-0.5.3 → ferogram-0.5.3b2}/README.md +1 -1
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/client.py +147 -14
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/proxy.py +82 -9
- {ferogram-0.5.3 → ferogram-0.5.3b2}/pyproject.toml +1 -1
- {ferogram-0.5.3 → ferogram-0.5.3b2}/src/connection.rs +137 -1
- {ferogram-0.5.3 → ferogram-0.5.3b2}/src/message_box.rs +43 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/.github/workflows/compile-check.yml +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/.github/workflows/publish.yml +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/.gitignore +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/.readthedocs.yaml +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/CHANGELOG.md +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/Cargo.lock +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/Cargo.toml +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/FEATURES.md +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/LICENSE-APACHE +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/LICENSE-MIT +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/Makefile +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/assets/architecture.svg +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/build.rs +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/admin_tools.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/command_bot.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/echo_bot.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/group_management.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/media_bot.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/raw_invoke.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/rich_message.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/search_bot.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/send_hi.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/send_media.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/send_message.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/update_handlers.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/user_management.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/examples/userbot.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/__init__.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/api.tl +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/filters.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/keyboards.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/logging.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/py.typed +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/__init__.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/api/__init__.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/api/functions.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/api/types.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/codegen.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/__init__.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/_tl_schema.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/__init__.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/account.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/aicompose.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/auth.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/bots.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/channels.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/chatlists.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/communities.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/contacts.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/ephemeral.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/folders.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/fragment.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/help.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/langpack.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/messages.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/payments.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/phone.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/photos.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/premium.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/smsjobs.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/stats.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/stickers.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/stories.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/updates.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/upload.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/functions/users.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/__init__.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/_base.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/account.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/aicompose.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/auth.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/bots.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/channels.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/chatlists.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/communities.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/contacts.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/fragment.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/help.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/messages.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/payments.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/phone.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/photos.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/premium.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/smsjobs.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/stats.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/stickers.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/storage.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/stories.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/updates.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/upload.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/generated/types/users.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/raw/tl.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/rich.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/types.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/ferogram/updates.py +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/src/lib.rs +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/src/pipelined.rs +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/src/raw.rs +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/src/session.rs +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/src/srp.rs +0 -0
- {ferogram-0.5.3 → ferogram-0.5.3b2}/tests/test_rich.py +0 -0
- {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
|
+
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
|

|
|
33
33
|

|
|
34
34
|
|
|
35
|
-
**A modern, elegant, asynchronous MTProto framework for building Telegram
|
|
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
|

|
|
9
9
|

|
|
10
10
|
|
|
11
|
-
**A modern, elegant, asynchronous MTProto framework for building Telegram
|
|
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.
|
|
954
|
-
#
|
|
955
|
-
#
|
|
956
|
-
#
|
|
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.
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
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)
|
|
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.
|
|
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::{
|
|
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
|
|
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
|
|
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
|
|
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
|