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
62 changes: 56 additions & 6 deletions app/max_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(','))))
Expand Down Expand Up @@ -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,
},
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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:
Expand Down
28 changes: 25 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,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))
Expand All @@ -311,7 +330,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
37 changes: 33 additions & 4 deletions 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 All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions tests/test_max_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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
100 changes: 100 additions & 0 deletions tests/test_skip_muted_listener.py
Original file line number Diff line number Diff line change
@@ -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) == "✅ <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
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
Loading