Skip to content

Commit e46e952

Browse files
committed
chore: upgrade PyMax to 2.1.2
1 parent 8793af5 commit e46e952

8 files changed

Lines changed: 19 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ All notable changes to Maxgram are documented here.
1717
- **Architecture decision ADR-005** — documents the account migration recovery registry, privacy constraints, remap behavior, and V1 non-automation boundaries.
1818

1919
### Changed
20+
- **PyMax 2.1.2 upgrade**`maxapi-python` is pinned to 2.1.2. Bridge login validation now accepts tokenless `LOGIN` responses without re-injecting the saved session token, while keeping backend-local sanitizers for unsupported initial-sync payload drift.
2021
- **Telegram command access model**`/dm` remains public only in General via an explicit allowlist; `/recovery ...` and other arg commands remain owner-only even in General.
2122
- **Remap-safe reply routing** — after `/recovery remap`, replies to old Telegram messages no longer send stale MAX `reply_to_msg_id` values when the mapped MAX message belongs to the old chat id.
2223
- **Recovery snapshot upsert deltas**`upsert_recovery_snapshot()` now returns `inserted`, `status_changed`, `unmapped`, `needs_invite`, and `manual_admin_required`, and stores a redacted scan reason in recovery events.
@@ -32,6 +33,7 @@ All notable changes to Maxgram are documented here.
3233
- **Top-level MAX voice payloads** — raw notifications where `payload` itself is the message and media is stored under `attachments` are now normalized before pymax can drop the attachment list.
3334

3435
### Tests
36+
- Added PyMax 2.1.2 runtime version pinning and a regression test for tokenless `LoginResponse` validation.
3537
- Added coverage for durable inbound/outbound text queues, plaintext clearing after delivery, stale MAX transport readiness, non-queued ambiguous ack timeouts, and non-persisted TG→MAX media failures.
3638
- Added coverage for DM title resolution order, cached contact name lookup, raw message interceptor, duplicate suppression, top-level raw audio payloads, and recent-history recovery of typed-empty MAX voice events.
3739
- Added coverage for SQLite recovery migrations/idempotency/deltas, DM contact recovery upsert/export/privacy, recovery report/export/remap, MAX recovery snapshot collection, async event-driven recovery scans, quiet status-summary recovery alerts, account-migration notification privacy/deduplication, owner-only `/recovery`, command allowlist privacy, stale reply routing after remap, and privacy of recovery reports/logs.

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Supervisor ──► Worker(MAX Adapter ──► Bridge Core ──► TG Adapt
9191
- Не делать профилактический/автоматический MAX reauth. Обычный offline/reboot/week-away сценарий должен использовать существующий persisted device token как обычный клиент; reauth разрешён только при явном `requires_reauth=true`/invalid token или осознанной ручной проверке с остановленным bridge. Перед reauth сохранять snapshot `session.db`; не запускать второй MAX-клиент рядом с работающим bridge.
9292
- `Opcode.SESSIONS_CLOSE` нельзя использовать как точечное закрытие одной старой сессии: live-проверка показала, что вызов с `{"time": ...}` привёл к `FAIL_LOGOUT_ALL` и инвалидировал desktop tokens. Старые MAX sessions закрывать только вручную в телефоне, пока не будет подтверждённого безопасного payload/API.
9393
- MAX initial sync может вернуть `lastMessage.attaches.type=UNSUPPORTED`; upstream PyMax 2 `LoginResponse` strict union падает. `src/adapters/max/backends/pymax/login.py` ставит backend-local `BridgeAuthService`, который удаляет только unknown attachments до validation.
94-
- PyMax 2.1.x `LoginResponse` требует `token`, но MAX может не вернуть его при логине с уже существующей session. `BridgeAuthService` в `src/adapters/max/backends/pymax/login.py` перед validation безопасно подставляет текущий `session.token` и логирует только факт repair (`filled_token_from_session=true`), не значение token.
94+
- PyMax 2.1.2 `LoginResponse.token` optional: MAX может не вернуть новый token при логине с уже существующей session, а upstream `App.start()` должен сохранить текущий session token. `BridgeAuthService` больше не подставляет `session.token` в response; он остаётся только для unsupported attachments / non-critical initial-sync payload drift.
9595
- PyMax 2 может отдать `message.text` как `bytes`; для SHARE/сложных payload это может быть msgpack-like binary, а не plain UTF-8. `MaxEventsService` сначала пробует извлечь `text` через msgpack, затем strict UTF-8, и не форвардит binary garbage с ``.
9696
- PyMax 2 TCP msgpack decoder может падать на raw `CHAT_HISTORY`, если MAX отдаёт map с array-like key (`TypeError: unhashable type: 'list'`). `src/adapters/max/backends/pymax/transport.py` ставит backend-local `BridgeMsgpackPayloadCodec`, который конвертирует такие keys в hashable форму до нормализации payload.
9797
- PyMax 2 TCP `seq` в upstream растёт до `0xFFFFFFFF`, но TCP framer пакует его в one-byte поле; после `255` возникает `struct.error: 'B' format requires 0 <= number <= 255` и ломаются `CHAT_HISTORY`/TG→MAX sends. `src/adapters/max/backends/pymax/transport.py` ставит `BridgeConnectionManager`/sequence guard с wrap `% 0x100`; regression marker: `pymax_tcp_sequence_overflow`.

docs/decisions/ADR-004-pymax-reconnect-strategy.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
**Статус:** Принято
44
**Дата:** 2026-04
5-
**Обновлено:** 2026-05-28 для PyMax 2.1.1
5+
**Обновлено:** 2026-06-04 для PyMax 2.1.2
66
**Контекст:** Обнаружен баг при отладке production
77

88
## Проблема
@@ -53,3 +53,4 @@ async def _make_client(self):
5353
- Readiness не равен только `_started`: после network/router flap PyMax 2 может закрыть внутренний transport, но не вернуть управление из `start()`. Поэтому `MaxAdapter.is_ready()` проверяет `client.is_connected`; watchdog видит закрытый transport и после grace-period может выполнить self-heal restart процесса.
5454
- PyMax 2.1.0 исправил TCP header/seq layout (`seq` стал 16-bit). Bridge больше не заворачивает `seq` на 256; `BridgeConnectionManager` оставлен как bridge-owned egress connection boundary с тем же 16-bit range и regression guard.
5555
- PyMax 2.1.1 исправил upstream TLS `server_hostname` при TCP proxy и сохранение обновлённого session token после login/`close_all_sessions()`. Bridge сохраняет свой `EgressTCPTransport` как MAX-only egress boundary и проверяет `server_hostname` regression-тестом.
56+
- PyMax 2.1.2 сделал `LoginResponse.token` optional и сохраняет текущий session token, когда `LOGIN` не возвращает новый token. Bridge больше не подставляет token в login response; backend-local `BridgeAuthService` остаётся только для tolerant validation initial-sync payload drift.

docs/tests.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ PYTHONPATH=. .venv/bin/python -m compileall src tests
1515
.venv/bin/mypy --check-untyped-defs --no-implicit-optional --ignore-missing-imports --follow-imports=silent src/bridge/core.py src/bridge/status.py src/bridge/media_retry.py src/bridge/recovery/scheduler.py src/bridge/commands/dispatcher.py
1616
```
1717

18-
Всего: **279 тестов**, async-тесты идут через `pytest-asyncio`, property-based parser guards — через `hypothesis`. Внешних зависимостей нет: SQLite через `tmp_path`, MAX и Telegram заменены stub/fake-классами.
18+
Всего: **286 тестов**, async-тесты идут через `pytest-asyncio`, property-based parser guards — через `hypothesis`. Внешних зависимостей нет: SQLite через `tmp_path`, MAX и Telegram заменены stub/fake-классами.
1919

2020
GitHub Actions выполняет тот же gate: `compileall`, repo-level `ruff check`, scoped bridge `ruff`, scoped `mypy` для MAX/bridge boundaries, затем `pytest --cov=src --cov-report=term-missing --cov-report=xml --cov-report=html --cov-fail-under=75`. HTML/XML coverage отчёты загружаются artifact-ом `coverage-report`.
2121

22-
Тесты с marker `architecture` — это service-boundary/refactoring guards (`test_bridge_contracts.py`, `test_max_adapter_leaves.py`, `test_pymax_surface_pin.py`). Их можно отделить от бизнес-регресса командой `pytest -m "not architecture"`; пока они остаются частью полного gate и не отключены.
22+
Тесты с marker `architecture` — это service-boundary/refactoring guards (`test_bridge_contracts.py`, `test_max_adapter_leaves.py`, `test_pymax_surface_pin.py`). `test_pymax_surface_pin.py` также фиксирует runtime version `pymax.__version__ == "2.1.2"`. Их можно отделить от бизнес-регресса командой `pytest -m "not architecture"`; пока они остаются частью полного gate и не отключены.
2323

2424
```text
2525
pytest -q
@@ -269,6 +269,7 @@ Raw payload implementation is split behind `src/adapters/max/raw_payload.py`: pa
269269
| `test_max_reauth_snapshot_session_db_copies_without_token_output` | Перед reauth создаётся `session.db.before-reauth-*` snapshot с правами `0600`, без чтения/печати token. |
270270
| `test_pymax2_login_payload_drops_unsupported_attachments` | `login.py` удаляет unsupported attachments из initial sync payload до strict PyMax 2 `LoginResponse` validation. |
271271
| `test_pymax2_login_validation_repairs_noncritical_payload_drift` | `validate_login_response()` не валит MAX runtime из-за одного битого `lastMessage`/history/contact узла initial sync. |
272+
| `test_pymax212_login_validation_allows_tokenless_response` | PyMax 2.1.2 `LoginResponse.token=None` принимается без bridge-side подстановки текущего session token. |
272273
| `test_pymax2_login_validation_error_is_safe_and_classified` | Невосстановимый PyMax payload drift логируется без raw payload/`input_val` и классифицируется как `pymax_payload_drift`, reauth не нужен. |
273274
| `test_pymax2_handler_signatures_are_adapted_to_bridge_callbacks` | PyMax 2 callbacks `(event, client)` адаптируются к bridge callbacks без pymax types снаружи. |
274275
| `test_pymax2_raw_gateway_converts_frames_and_invokes_app` | Native `on_raw` конвертируется в bridge raw dict, а raw requests изолированы через `_app.invoke`. |

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
aiogram==3.26.0
33

44
# MAX userbot
5-
maxapi-python==2.1.1
5+
maxapi-python==2.1.2
66
requests>=2.31,<3
77

88
# Database

src/adapters/max/backends/pymax/login.py

Lines changed: 2 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,9 @@ def sanitize_login_payload(payload: dict[str, Any]) -> dict[str, Any]:
6969

7070
def validate_login_response(
7171
payload: dict[str, Any],
72-
*,
73-
session_token: str | None = None,
7472
) -> LoginResponse:
7573
"""Validate login response while tolerating non-critical PyMax model drift."""
7674
sanitized = sanitize_login_payload(payload)
77-
filled_token = _fill_missing_token_from_session(
78-
sanitized,
79-
session_token=session_token,
80-
)
8175
try:
8276
response = LoginResponse.model_validate(sanitized)
8377
except ValidationError as exc:
@@ -89,21 +83,12 @@ def validate_login_response(
8983
errors=_safe_validation_errors(exc),
9084
) from exc
9185
else:
92-
if filled_token:
93-
_log_login_payload_repaired(
94-
dropped_last_messages=0,
95-
dropped_messages=0,
96-
nulled_contacts=0,
97-
filled_token_from_session=True,
98-
validation_paths=[],
99-
)
10086
return response
10187

10288
_log_login_payload_repaired(
10389
dropped_last_messages=repair.dropped_last_messages,
10490
dropped_messages=repair.dropped_messages,
10591
nulled_contacts=repair.nulled_contacts,
106-
filled_token_from_session=filled_token,
10792
validation_paths=repair.validation_paths[:8],
10893
)
10994
try:
@@ -148,20 +133,6 @@ def _is_message_element(value: dict[str, Any]) -> bool:
148133
return isinstance(value.get("type"), str) and isinstance(value.get("length"), int)
149134

150135

151-
def _fill_missing_token_from_session(
152-
payload: dict[str, Any],
153-
*,
154-
session_token: str | None,
155-
) -> bool:
156-
if not session_token:
157-
return False
158-
token = payload.get("token")
159-
if isinstance(token, str) and token:
160-
return False
161-
payload["token"] = session_token
162-
return True
163-
164-
165136
def _safe_validation_errors(exc: ValidationError) -> list[dict[str, Any]]:
166137
return exc.errors(include_url=False, include_input=False)
167138

@@ -290,7 +261,6 @@ def _log_login_payload_repaired(
290261
dropped_last_messages: int,
291262
dropped_messages: int,
292263
nulled_contacts: int,
293-
filled_token_from_session: bool,
294264
validation_paths: list[str],
295265
) -> None:
296266
log_event(
@@ -303,7 +273,6 @@ def _log_login_payload_repaired(
303273
dropped_last_messages=dropped_last_messages,
304274
dropped_messages=dropped_messages,
305275
nulled_contacts=nulled_contacts,
306-
filled_token_from_session=filled_token_from_session,
307276
validation_paths=validation_paths,
308277
)
309278

@@ -328,10 +297,7 @@ async def mobile_login(self) -> LoginResponse:
328297
sync=sync,
329298
)
330299
response = await self.app.invoke(Opcode.LOGIN, frame.to_payload())
331-
login_response = validate_login_response(
332-
response.payload,
333-
session_token=session.token,
334-
)
300+
login_response = validate_login_response(response.payload)
335301
await self._update_session(login_response)
336302
return login_response
337303

@@ -346,9 +312,6 @@ async def web_login(self) -> LoginResponse:
346312
sync=sync,
347313
)
348314
response = await self.app.invoke(Opcode.LOGIN, frame.to_payload())
349-
login_response = validate_login_response(
350-
response.payload,
351-
session_token=session.token,
352-
)
315+
login_response = validate_login_response(response.payload)
353316
await self._update_session(login_response)
354317
return login_response

tests/test_max_adapter_leaves.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ def test_pymax2_login_validation_repairs_noncritical_payload_drift():
515515
assert response.contacts == [None]
516516

517517

518-
def test_pymax2_login_validation_fills_missing_token_from_session():
518+
def test_pymax212_login_validation_allows_tokenless_response():
519519
from src.adapters.max.backends.pymax.login import validate_login_response
520520

521521
response = validate_login_response(
@@ -524,11 +524,10 @@ def test_pymax2_login_validation_fills_missing_token_from_session():
524524
"chats": [],
525525
"messages": {},
526526
"contacts": [],
527-
},
528-
session_token="existing-session-token",
527+
}
529528
)
530529

531-
assert response.token == "existing-session-token"
530+
assert response.token is None
532531

533532

534533
def test_pymax2_login_validation_error_is_safe_and_classified():

tests/test_pymax_surface_pin.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
import importlib
44

55
import pytest
6+
import pymax
67

78

89
pytestmark = pytest.mark.architecture
910

1011

12+
def test_pymax_runtime_version_is_pinned():
13+
assert pymax.__version__ == "2.1.2"
14+
15+
1116
PINS = {
1217
"pymax": ("Client", "ExtraConfig", "SyncOverrides", "File", "Message", "Photo", "Video"),
1318
"pymax.client": ("Client",),

0 commit comments

Comments
 (0)