diff --git a/app/max_client.py b/app/max_client.py
index c2e0d93..e79e573 100644
--- a/app/max_client.py
+++ b/app/max_client.py
@@ -119,6 +119,7 @@ def __init__(self, token: str, device_id: str, chat_ids: str | None = None, debu
self._on_disconnect_cb = None
self._auth_pending = False
self._auth_timeout_task: asyncio.Task | None = None
+ self._config_hash: str | None = None
self.chat_ids: list[int] = []
if chat_ids:
self.chat_ids.extend(map(int, map(str.strip, chat_ids.split(','))))
@@ -315,7 +316,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,
},
@@ -325,6 +330,9 @@ async def _handle(self, data: dict):
elif op == OpCode.AUTH_SNAPSHOT and cmd == 1:
self._clear_auth_timeout()
self._my_id = payload.get("profile", {}).get("id")
+ snapshot_hash = payload.get("hash")
+ if snapshot_hash is not None:
+ self._config_hash = str(snapshot_hash)
log.info("Authorized! my_id=%s", self._my_id)
if self.debug:
self._dump_json("snapshot.json", payload)
@@ -338,13 +346,16 @@ 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):
+ elif op == OpCode.NOTIF_CONFIG:
if self.mute_tracker:
- self.mute_tracker.on_settings(payload)
+ config = payload.get("config") or {}
+ config_hash = config.get("hash")
+ if config_hash is not None:
+ self._config_hash = str(config_hash)
+ task = asyncio.create_task(self._refresh_mute_settings(config_hash))
+ task.add_done_callback(_log_task_exception)
elif op == OpCode.DISPATCH:
self._dispatch_counter += 1
@@ -369,6 +380,45 @@ async def _handle(self, data: dict):
# ── WebSocket RPC: fetch contacts ──────────────────────────────
+ @staticmethod
+ def _settings_has_chats(resp: dict) -> bool:
+ if not resp:
+ return False
+ if isinstance(resp.get("chats"), dict):
+ return True
+ settings = resp.get("settings")
+ return isinstance(settings, dict) and isinstance(settings.get("chats"), dict)
+
+ async def fetch_settings(self, config_hash: str | None = None) -> dict:
+ """Request per-chat notification settings (mute) via opcode 22."""
+ candidates: list[dict] = []
+ h = config_hash or self._config_hash
+ if h:
+ candidates.extend([
+ {"hash": h},
+ {"config": {"hash": h}},
+ ])
+ candidates.extend([{}, {"settings": {}}])
+
+ for payload in candidates:
+ resp = await self.cmd(OpCode.CONFIG, payload, timeout=8)
+ if self._settings_has_chats(resp):
+ if resp.get("hash") is not None:
+ self._config_hash = str(resp["hash"])
+ return resp
+ return {}
+
+ async def _refresh_mute_settings(self, config_hash: str | None = None) -> None:
+ if not self.mute_tracker:
+ return
+ before = self.mute_tracker.muted_count()
+ resp = await self.fetch_settings(config_hash)
+ if resp:
+ self.mute_tracker.update_from_payload(resp)
+ after = self.mute_tracker.muted_count()
+ if resp or after != before:
+ log.info("Mute state refreshed from CONFIG: %d muted chat(s)", after)
+
async def fetch_contacts(self, contact_ids: list[int]) -> dict:
"""Fetch contact info via WS opcode 32. Returns raw response payload."""
if not contact_ids:
diff --git a/app/max_listener.py b/app/max_listener.py
index 198520a..7d662ca 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,20 @@ 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)
+ settings_resp = await client.fetch_settings(snapshot.get("hash"))
+ if settings_resp:
+ client.mute_tracker.update_from_payload(settings_resp)
+ log.info(
+ "Mute state after CONFIG fetch: %d muted chat(s)",
+ client.mute_tracker.muted_count(),
+ )
+ elif client.mute_tracker.muted_count() == 0:
+ log.warning(
+ "Mute settings not in snapshot/CONFIG (hash=%s). "
+ "Will refresh on NOTIF_CONFIG from Max.",
+ snapshot.get("hash"),
+ )
if participant_ids:
log.info("Batch-resolving %d participants...", len(participant_ids))
@@ -311,7 +330,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..7488f09 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)
@@ -70,20 +99,20 @@ def _apply(self, chat_id: Any, dont_disturb_until: int) -> None:
if dont_disturb_until == 0:
self._permanent.discard(chat_id)
self._until.pop(chat_id, None)
- log.debug("Chat unmuted: %s", chat_id)
+ log.info("Chat unmuted: %s", chat_id)
return
if dont_disturb_until == -1:
self._permanent.add(chat_id)
self._until.pop(chat_id, None)
- log.debug("Chat muted permanently: %s", chat_id)
+ log.info("Chat muted permanently: %s", chat_id)
return
now_ms = int(time.time() * 1000)
if dont_disturb_until > now_ms:
self._permanent.add(chat_id)
self._until[chat_id] = dont_disturb_until
- log.debug("Chat muted until %s: %s", dont_disturb_until, chat_id)
+ log.info("Chat muted until %s: %s", dont_disturb_until, chat_id)
else:
self._permanent.discard(chat_id)
self._until.pop(chat_id, None)
diff --git a/tests/test_max_client.py b/tests/test_max_client.py
index 3d60040..21e9c67 100644
--- a/tests/test_max_client.py
+++ b/tests/test_max_client.py
@@ -296,3 +296,18 @@ def test_masks_max_token_env_like_string(self):
masked = MaxClient._mask_sensitive(text)
assert 'my-secret-token' not in masked
assert 'MAX_TOKEN=***' in masked
+
+
+class TestSettingsHasChats:
+ def test_top_level_chats(self):
+ assert MaxClient._settings_has_chats({"chats": {"1": {"dontDisturbUntil": -1}}}) is True
+
+ def test_nested_settings_chats(self):
+ assert MaxClient._settings_has_chats({
+ "settings": {"chats": {"1": {"dontDisturbUntil": -1}}},
+ }) is True
+
+ def test_empty_or_missing(self):
+ assert MaxClient._settings_has_chats({}) is False
+ assert MaxClient._settings_has_chats({"settings": {}}) is False
+
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..aefb977
--- /dev/null
+++ b/tests/test_skip_muted_listener.py
@@ -0,0 +1,100 @@
+"""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
+ client.fetch_settings = AsyncMock(return_value={})
+ 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