Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions app/max_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand All @@ -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
Expand Down
15 changes: 12 additions & 3 deletions app/max_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"✅ <b>Max:</b> подключён | чатов: {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,
Expand Down Expand Up @@ -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))
Expand All @@ -311,7 +317,10 @@ async def handle_ready(snapshot: dict):
await sender.send("✅ <b>Max:</b> соединение восстановлено")
else:
chat_count = len(resolver.chats)
await sender.send(f"✅ <b>Max:</b> подключён | чатов: {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
Expand Down
31 changes: 30 additions & 1 deletion app/mute_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_mute_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
115 changes: 115 additions & 0 deletions tests/test_skip_muted_listener.py
Original file line number Diff line number Diff line change
@@ -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) == "✅ <b>Max:</b> подключён | чатов: 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
Loading