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
131 changes: 125 additions & 6 deletions app/max_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(','))))
Expand Down Expand Up @@ -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,
},
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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:
Expand Down
77 changes: 65 additions & 12 deletions app/max_listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<i>[аудио — не удалось загрузить]</i>", reply_markup=kb)
return True


async def _send_attach(
attach: dict,
client: MaxClient,
Expand Down Expand Up @@ -117,15 +153,10 @@ async def _send_attach(
await sender.send(f"{header_text}\n📎 <b>{escape(name)}</b>{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<i>[аудио]</i>", 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")
Expand Down Expand Up @@ -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"✅ <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 +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))
Expand All @@ -311,7 +361,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
34 changes: 34 additions & 0 deletions tests/test_max_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Loading
Loading