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