diff --git a/app/max_client.py b/app/max_client.py
index c2e0d93..b1df1f0 100644
--- a/app/max_client.py
+++ b/app/max_client.py
@@ -71,6 +71,8 @@ class OpCode(IntEnum):
SEND_MESSAGE = 64
EDIT_MESSAGE = 67
GET_FILE_URL = 88
+ VIDEO_PLAY = 83
+ AUDIO_PLAY = 301
CONFIG = 22
NOTIF_MARK = 130
NOTIF_CONFIG = 134
@@ -119,6 +121,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 +318,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 +332,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 +348,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 +382,112 @@ 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)
+
+ @staticmethod
+ def _extract_play_url(resp: dict) -> str | None:
+ """Pick a playable/downloadable URL from VIDEO_PLAY / AUDIO_PLAY responses."""
+ if not resp:
+ return None
+ direct = resp.get("url")
+ if isinstance(direct, str) and direct.startswith("http"):
+ return direct
+ skip = {"cache", "EXTERNAL"}
+ for key, value in resp.items():
+ if key in skip:
+ continue
+ if isinstance(value, str) and value.startswith("http"):
+ return value
+ return None
+
+ async def resolve_attach_url(
+ self, attach: dict, chat_id: Any, message_id: Any,
+ ) -> str | None:
+ """Resolve a download URL for FILE / AUDIO / VIDEO attachments."""
+ for key in ("url", "baseUrl"):
+ direct = attach.get(key)
+ if isinstance(direct, str) and direct.startswith("http"):
+ return direct
+
+ if chat_id is None or not message_id:
+ return None
+
+ base = {"chatId": chat_id, "messageId": message_id}
+ file_id = attach.get("fileId")
+ if file_id is not None:
+ resp = await self.cmd(OpCode.GET_FILE_URL, {**base, "fileId": file_id})
+ url = resp.get("url")
+ if url:
+ return url
+
+ token = attach.get("token")
+ if token is not None:
+ resp = await self.cmd(OpCode.AUDIO_PLAY, {**base, "token": token})
+ url = self._extract_play_url(resp)
+ if url:
+ log.info("Resolved audio URL via AUDIO_PLAY token")
+ return url
+ resp = await self.cmd(OpCode.GET_FILE_URL, {**base, "token": token})
+ url = resp.get("url")
+ if url:
+ log.info("Resolved audio URL via GET_FILE_URL token")
+ return url
+
+ audio_id = attach.get("audioId")
+ if audio_id is not None:
+ resp = await self.cmd(OpCode.AUDIO_PLAY, {**base, "audioId": audio_id})
+ url = self._extract_play_url(resp)
+ if url:
+ log.info("Resolved audio URL via AUDIO_PLAY audioId")
+ return url
+
+ video_id = attach.get("videoId")
+ if video_id is not None:
+ resp = await self.cmd(OpCode.VIDEO_PLAY, {**base, "videoId": video_id})
+ url = self._extract_play_url(resp)
+ if url:
+ log.info("Resolved video URL via VIDEO_PLAY")
+ return url
+
+ return None
+
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..e487614 100644
--- a/app/max_listener.py
+++ b/app/max_listener.py
@@ -47,6 +47,42 @@ def _guess_media_kind(filename: str) -> str:
return "document"
+def _looks_like_voice(attach: dict) -> bool:
+ """Detect voice/audio notes, including Max _type UNSUPPORTED with token."""
+ atype = attach.get("_type", "")
+ if atype in ("AUDIO", "VOICE"):
+ return True
+ if atype in ("UNSUPPORTED", "UNKNOWN"):
+ return bool(
+ attach.get("token")
+ or attach.get("audioId")
+ or attach.get("duration") is not None
+ or attach.get("wave")
+ )
+ return False
+
+
+async def _send_voice_attach(
+ attach: dict,
+ client: MaxClient,
+ sender: TelegramSender,
+ header_text: str,
+ chat_id: Any,
+ message_id: Any,
+ kb=None,
+) -> bool:
+ url = _extract_file_url(attach) or attach.get("baseUrl")
+ if not url:
+ url = await client.resolve_attach_url(attach, chat_id, message_id)
+ if url:
+ data = await client.download_file(url)
+ if data:
+ await sender.send_voice(data, caption=header_text, reply_markup=kb)
+ return True
+ await sender.send(f"{header_text}\n[аудио — не удалось загрузить]", reply_markup=kb)
+ return True
+
+
async def _send_attach(
attach: dict,
client: MaxClient,
@@ -117,15 +153,10 @@ async def _send_attach(
await sender.send(f"{header_text}\n📎 {escape(name)}{size_str}", reply_markup=kb)
return True
- if atype == "AUDIO":
- url = attach.get("url")
- if url:
- data = await client.download_file(url)
- if data:
- await sender.send_voice(data, caption=header_text, reply_markup=kb)
- return True
- await sender.send(f"{header_text}\n[аудио]", reply_markup=kb)
- return True
+ if atype == "AUDIO" or _looks_like_voice(attach):
+ return await _send_voice_attach(
+ attach, client, sender, header_text, chat_id, message_id, kb=kb,
+ )
if atype == "STICKER":
url = attach.get("url")
@@ -252,6 +283,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 +326,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 +361,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..9db3d52 100644
--- a/tests/test_max_client.py
+++ b/tests/test_max_client.py
@@ -296,3 +296,37 @@ 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
+
+
+class TestMediaOpcodes:
+ def test_video_play_opcode(self):
+ assert OpCode.VIDEO_PLAY == 83
+
+ def test_audio_play_opcode(self):
+ assert OpCode.AUDIO_PLAY == 301
+
+
+class TestExtractPlayUrl:
+ def test_direct_url(self):
+ assert MaxClient._extract_play_url({"url": "https://cdn.example/a.ogg"}) == "https://cdn.example/a.ogg"
+
+ def test_skips_cache_keys(self):
+ assert MaxClient._extract_play_url({
+ "cache": "x",
+ "MEDIUM": "https://cdn.example/a.mp3",
+ }) == "https://cdn.example/a.mp3"
+
diff --git a/tests/test_max_listener.py b/tests/test_max_listener.py
index 0f8440b..47c44ff 100644
--- a/tests/test_max_listener.py
+++ b/tests/test_max_listener.py
@@ -1,7 +1,54 @@
"""Tests for app/max_listener.py — pure helper functions."""
import pytest
-from app.max_listener import _human_size, _guess_media_kind
+from unittest.mock import AsyncMock
+
+from app.max_listener import (
+ _guess_media_kind,
+ _human_size,
+ _looks_like_voice,
+ _send_voice_attach,
+)
+from app.max_client import MaxClient
+
+
+class TestLooksLikeVoice:
+ def test_audio_type(self):
+ assert _looks_like_voice({"_type": "AUDIO"}) is True
+
+ def test_unsupported_with_token(self):
+ assert _looks_like_voice({"_type": "UNSUPPORTED", "token": "abc"}) is True
+
+ def test_unsupported_without_audio_fields(self):
+ assert _looks_like_voice({"_type": "UNSUPPORTED"}) is False
+
+ def test_photo_not_voice(self):
+ assert _looks_like_voice({"_type": "PHOTO", "url": "http://x"}) is False
+
+
+class TestExtractPlayUrl:
+ def test_direct_url(self):
+ assert MaxClient._extract_play_url({"url": "https://cdn.example/a.ogg"}) == "https://cdn.example/a.ogg"
+
+ def test_nested_format_url(self):
+ assert MaxClient._extract_play_url({"HIGH": "https://cdn.example/a.mp3"}) == "https://cdn.example/a.mp3"
+
+
+class TestSendVoiceAttach:
+ async def test_unsupported_token_resolves_and_sends(self):
+ client = AsyncMock(spec=MaxClient)
+ client.resolve_attach_url = AsyncMock(return_value="https://cdn.example/v.ogg")
+ client.download_file = AsyncMock(return_value=b"audio-bytes")
+ sender = AsyncMock()
+
+ attach = {"_type": "UNSUPPORTED", "token": "voice-token", "duration": 3000}
+ ok = await _send_voice_attach(
+ attach, client, sender, "header", chat_id=1, message_id="m1",
+ )
+
+ assert ok is True
+ client.resolve_attach_url.assert_awaited_once()
+ sender.send_voice.assert_awaited_once()
# ---------------------------------------------------------------------------
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