From debb1ca8d154b52e45a3ce6563a208061e412cd3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 18 Jun 2026 11:23:57 +0000 Subject: [PATCH] Fix CONFIG opcode timeouts on startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opcode 22 (CONFIG) is write-only in the Max protocol — it sets mute settings but does not return them. The previous fetch_settings() RPC blocked startup with 8s timeouts per attempt. Load mute state from AUTH_SNAPSHOT (chats + settings.chats) instead, request a fuller snapshot (chatsCount 40 + sync params), and apply live updates from NOTIF_CHAT / NOTIF_CONFIG payloads. Co-authored-by: eGoR --- app/max_client.py | 12 ++-- app/max_listener.py | 15 +++- app/mute_tracker.py | 31 +++++++- tests/test_mute_tracker.py | 46 ++++++++++++ tests/test_skip_muted_listener.py | 115 ++++++++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 tests/test_skip_muted_listener.py diff --git a/app/max_client.py b/app/max_client.py index c2e0d93..485ed9e 100644 --- a/app/max_client.py +++ b/app/max_client.py @@ -315,7 +315,11 @@ async def _handle(self, data: dict): await self._send( OpCode.AUTH_SNAPSHOT, { - "chatsCount": 10, + "chatsCount": 40, + "chatsSync": 0, + "contactsSync": 0, + "presenceSync": 0, + "draftsSync": 0, "interactive": True, "token": self.token, }, @@ -338,13 +342,11 @@ async def _handle(self, data: dict): elif op == OpCode.NOTIF_CHAT: if self.mute_tracker: - chat = payload.get("chat") - if isinstance(chat, dict): - self.mute_tracker.update_chat(chat) + self.mute_tracker.update_from_payload(payload) elif op in (OpCode.NOTIF_CONFIG, OpCode.CONFIG): if self.mute_tracker: - self.mute_tracker.on_settings(payload) + self.mute_tracker.update_from_payload(payload) elif op == OpCode.DISPATCH: self._dispatch_counter += 1 diff --git a/app/max_listener.py b/app/max_listener.py index 198520a..b4247ef 100644 --- a/app/max_listener.py +++ b/app/max_listener.py @@ -252,6 +252,13 @@ def _human_size(n: int) -> str: return f"{n:.1f} ТБ" +def _startup_message(chat_count: int, muted_count: int | None = None) -> str: + text = f"✅ Max: подключён | чатов: {chat_count}" + if muted_count is not None: + text += f" | 🔇 из них без звука: {muted_count}" + return text + + def create_max_client( max_token: str, max_device_id: str, sender: TelegramSender, max_chat_ids: str | None = None, debug: bool = False, reply_enabled: bool = False, @@ -288,8 +295,7 @@ async def handle_ready(snapshot: dict): log.info("Unread-only mode: read marks loaded from snapshot") if client.mute_tracker: - client.mute_tracker.load_from_chats(snapshot.get("chats", [])) - log.info("Skip-muted mode: mute state loaded from snapshot") + client.mute_tracker.load_from_snapshot(snapshot) if participant_ids: log.info("Batch-resolving %d participants...", len(participant_ids)) @@ -311,7 +317,10 @@ async def handle_ready(snapshot: dict): await sender.send("✅ Max: соединение восстановлено") else: chat_count = len(resolver.chats) - await sender.send(f"✅ Max: подключён | чатов: {chat_count}") + muted_count = None + if client.skip_muted and client.mute_tracker: + muted_count = client.mute_tracker.muted_count() + await sender.send(_startup_message(chat_count, muted_count)) _first_connect = False @client.on_disconnect diff --git a/app/mute_tracker.py b/app/mute_tracker.py index 4775b50..94da3fc 100644 --- a/app/mute_tracker.py +++ b/app/mute_tracker.py @@ -43,8 +43,37 @@ def load_from_chats(self, chats: list) -> None: if isinstance(chat, dict) and chat.get("id") is not None: self.update_chat(chat) + def load_from_snapshot(self, snapshot: dict) -> None: + """Load mute state from AUTH_SNAPSHOT (chats list + settings/config maps).""" + self.load_from_chats(snapshot.get("chats", [])) + self.on_settings(snapshot) + config = snapshot.get("config") + if isinstance(config, dict): + self.on_settings(config) + log.info( + "Mute state loaded from snapshot: %d muted chat(s)", + len(self._permanent), + ) + + def muted_count(self) -> int: + return len(self._permanent) + + def update_from_payload(self, payload: dict) -> None: + """Apply mute updates from NOTIF_CHAT / NOTIF_CONFIG payloads.""" + if not isinstance(payload, dict): + return + chat = payload.get("chat") + if isinstance(chat, dict): + self.update_chat(chat) + return + if payload.get("id") is not None or payload.get("chatId") is not None: + self.update_chat(payload) + return + if payload.get("settings") is not None or payload.get("chats") is not None: + self.on_settings(payload) + def update_chat(self, chat: dict) -> None: - chat_id = _normalize_chat_id(chat.get("id")) + chat_id = _normalize_chat_id(chat.get("id") or chat.get("chatId")) if chat_id is None: return ddu = _extract_dont_disturb_until(chat) diff --git a/tests/test_mute_tracker.py b/tests/test_mute_tracker.py index 1b1afb3..96f455a 100644 --- a/tests/test_mute_tracker.py +++ b/tests/test_mute_tracker.py @@ -65,3 +65,49 @@ def test_notif_chat_update_unmutes(self): mt.update_chat({"id": 5, "dontDisturbUntil": -1}) mt.update_chat({"id": 5, "dontDisturbUntil": 0}) assert mt.is_muted(5) is False + + def test_load_from_snapshot_settings_chats(self): + mt = MuteTracker() + mt.load_from_snapshot({ + "chats": [ + {"id": 10, "title": "Visible"}, + ], + "settings": { + "chats": { + "-68093732121255": {"dontDisturbUntil": -1}, + "10": {"dontDisturbUntil": 0}, + } + }, + }) + assert mt.is_muted(-68093732121255) is True + assert mt.is_muted(10) is False + + def test_update_from_payload_nested_chat(self): + mt = MuteTracker() + mt.update_from_payload({ + "chat": {"id": 99, "dontDisturbUntil": -1}, + }) + assert mt.is_muted(99) is True + + def test_update_from_payload_settings(self): + mt = MuteTracker() + mt.update_from_payload({ + "settings": { + "chats": {"42": {"dontDisturbUntil": -1}}, + }, + }) + assert mt.is_muted(42) is True + + def test_update_from_payload_chat_id_top_level(self): + mt = MuteTracker() + mt.update_from_payload({ + "chatId": -123, + "dontDisturbUntil": -1, + }) + assert mt.is_muted(-123) is True + + def test_muted_count(self): + mt = MuteTracker() + assert mt.muted_count() == 0 + mt.update_chat({"id": 1, "dontDisturbUntil": -1}) + assert mt.muted_count() == 1 diff --git a/tests/test_skip_muted_listener.py b/tests/test_skip_muted_listener.py new file mode 100644 index 0000000..325d8aa --- /dev/null +++ b/tests/test_skip_muted_listener.py @@ -0,0 +1,115 @@ +"""Tests for skip-muted filtering in app/max_listener.py.""" + +from unittest.mock import AsyncMock + +import pytest + +from app.max_client import MaxMessage +from app.max_listener import _startup_message, create_max_client + + +class TestStartupMessage: + def test_without_muted_count(self): + assert _startup_message(32) == "✅ Max: подключён | чатов: 32" + + def test_with_muted_count(self): + text = _startup_message(32, 7) + assert "чатов: 32" in text + assert "🔇 из них без звука: 7" in text + + +@pytest.fixture +def muted_client(): + sender = AsyncMock() + client = create_max_client( + max_token="tok", + max_device_id="dev", + sender=sender, + skip_muted=True, + ) + return client, sender + + +class TestSkipMutedListener: + async def test_muted_chat_message_not_forwarded(self, muted_client): + client, sender = muted_client + await client._on_ready_cb({ + "profile": {"id": 1, "names": []}, + "chats": [], + "settings": { + "chats": { + "100": {"dontDisturbUntil": -1}, + } + }, + }) + sender.send.reset_mock() + + await client._on_message_cb( + MaxMessage(chat_id=100, sender_id=2, text="hello") + ) + sender.send.assert_not_called() + + async def test_unmuted_chat_message_forwarded(self, muted_client): + client, sender = muted_client + await client._on_ready_cb({ + "profile": {"id": 1, "names": []}, + "chats": [{"id": 200, "type": "DIALOG", "participants": {"1": {}, "2": {}}}], + "settings": {"chats": {}}, + }) + sender.send.reset_mock() + + await client._on_message_cb( + MaxMessage(chat_id=200, sender_id=2, text="hello") + ) + sender.send.assert_called() + + async def test_skip_muted_disabled_forwards_muted(self): + sender = AsyncMock() + client = create_max_client( + max_token="tok", + max_device_id="dev", + sender=sender, + skip_muted=False, + ) + await client._on_ready_cb({ + "profile": {"id": 1, "names": []}, + "chats": [{"id": 100, "dontDisturbUntil": -1, "type": "DIALOG", "participants": {}}], + }) + sender.send.reset_mock() + + await client._on_message_cb( + MaxMessage(chat_id=100, sender_id=2, text="hello") + ) + sender.send.assert_called() + + async def test_startup_shows_muted_count(self, muted_client): + client, sender = muted_client + await client._on_ready_cb({ + "profile": {"id": 1, "names": []}, + "chats": [{"id": 100, "type": "GROUP", "title": "Test", "participants": {}}], + "settings": { + "chats": { + "100": {"dontDisturbUntil": -1}, + "200": {"dontDisturbUntil": -1}, + } + }, + }) + text = sender.send.call_args[0][0] + assert "чатов: 1" in text + assert "🔇 из них без звука: 2" in text + + async def test_startup_shows_muted_count(self, muted_client): + client, sender = muted_client + await client._on_ready_cb({ + "profile": {"id": 1, "names": []}, + "chats": [{"id": 100, "type": "GROUP", "title": "Test", "participants": {}}], + "settings": { + "chats": { + "100": {"dontDisturbUntil": -1}, + "200": {"dontDisturbUntil": -1}, + } + }, + }) + text = sender.send.call_args[0][0] + assert "чатов: 1" in text + assert "🔇 из них без звука: 2" in text