Compare commits

...

9 Commits

Author SHA1 Message Date
zavolo
7d2e070d1f fix(chat history): фикс 2026-05-11 00:47:04 +03:00
zavolo
24b0123185 fix(chat history): фикс 2026-05-11 00:38:04 +03:00
zavolo
31844c7fa2 fix(chat history): фикс 2026-05-11 00:26:31 +03:00
zavolo
9b60b15538 fix(chat history): фикс 2026-05-11 00:11:24 +03:00
zavolo
0d91f6542e fix(chat history): фикс 2026-05-10 23:39:08 +03:00
zavolo
77d6ca8cc0 fix(chat history): фикс 2026-05-10 23:27:13 +03:00
zavolo
3bf8bc5770 fix(chat history): фикс 2026-05-10 23:21:07 +03:00
zavolo
861b75eb1c MAX: bootstrap-история в LOGIN — клиент перестал думать что всё уже синканулось
В ответе LOGIN сервер слал messages: {} и chatMarker: 0. Десктопный
клиент в этом случае считает, что локальная история уже синхронизирована
со старого запуска, и НЕ отправляет CHAT_HISTORY (49) при открытии чата.
В окне видно только lastMessage из chats[], а вся реальная переписка —
ничерта.

- src/common/tools.py: collect_bootstrap_history(chatIds, ...) —
  собирает карту {chatId: [последние N сообщений]}, в т.ч. избранное
  под клиентским id = senderId ^ senderId.
- src/oneme/processors/auth.py: подсовываем эту карту в
  payload.messages, chatMarker = текущее время вместо 0.
2026-05-10 22:27:42 +03:00
zavolo
fa0ed34adc MAX: история таки заработала — cid/link/reactionInfo обязательны в схеме
Десктопный MAX подключается через TCP (mobile-протокол) и парсит
msgpack по фиксированной схеме. Если в сообщении выпадает любое из
полей — клиент молча обрывает соединение. После 87cfc19 как раз
такие условные `if elements: ...` / `if link: ...` (а link и
reaction_info там всегда были `{}`, то есть falsy) вырезали поля
из ответа CHAT_HISTORY и MSG_SEND, чем и сломали историю.

- src/common/tools.py: новый build_message_dict() — единая сборка
  тела сообщения, где все поля (id, cid, time, type, sender, text,
  attaches, elements, reactionInfo, link) присутствуют ВСЕГДА.
  get_last_message переписан через него.
- src/oneme/processors/history.py: chat_history использует
  build_message_dict вместо ручной логики с условными if-ками.
- src/oneme/processors/messages.py: msg_send.bodyMessage теперь
  отдает cid / reactionInfo / link даже пустыми и приводит id
  к int для mobile, str для web.

Цепная польза: auth.py LOGIN bootstrap (через generate_chats →
get_last_message) и search.py тоже теперь шлют корректную схему.
2026-05-10 22:17:18 +03:00
3 changed files with 128 additions and 93 deletions

View File

@@ -10,6 +10,51 @@ class Tools:
def __init__(self):
pass
def build_message_dict(self, row, protocol_type="mobile"):
"""Унифицированная сборка тела сообщения для отправки клиенту.
Десктоп MAX (TCP, protocol_type='mobile') и официальный
api.oneme.ru ожидают, что в сообщении будут ВСЕГДА присутствовать
поля cid / elements / link / reactionInfo, даже если они пустые.
Любое отсутствие поля приводит к тому, что клиент бросает соединение
при разборе msgpack-схемы (классическая регрессия из коммита 87cfc19).
"""
try:
attaches = json.loads(row.get("attaches") or "[]")
except (TypeError, ValueError):
attaches = []
try:
elements = json.loads(row.get("elements") or "[]")
except (TypeError, ValueError):
elements = []
# Парсер MAX 26.15.3 (defpackage.u6h.Q) ждёт в сообщении следующие
# поля. Отсутствие любого ломает разбор msgpack-схемы, и клиент
# тихо роняет всю историю чата:
# id, cid, chatId, time, type, sender, text, attaches, elements,
# link, reactionInfo, updateTime, status, options
# Список вытащен дизассемблированием Q() через dexdump.
# type — int-enum для разновидности сообщения (0 = обычное text);
# status — int-enum (1 = ACTIVE/доставлено, 0 часто означает REMOVED).
message = {
"id": row.get("id") if protocol_type == "mobile" else str(row.get("id")),
"cid": int(row.get("cid") or 0),
"chatId": int(row.get("chat_id") or 0),
"time": int(row.get("time")),
"type": row.get("type") or "USER", # ENUM-строка: USER/CHANNEL/CHANNEL_ADMIN/GROUP
"sender": row.get("sender"),
"text": row.get("text") or "",
"attaches": attaches if isinstance(attaches, list) else [],
"elements": elements if isinstance(elements, list) else [],
"reactionInfo": {},
"link": {},
"updateTime": int(row.get("update_time") or row.get("time") or 0),
"status": int(row.get("status") or 1), # 1 = ACTIVE
"options": int(row.get("options") or 0),
}
return message
def generate_profile(
self,
id=1,
@@ -396,6 +441,44 @@ class Tools:
# Возвращаем айдишки
return int(message_id), int(last_message_id), message_time
async def collect_bootstrap_history(
self, chatIds, db_pool, senderId, protocol_type="mobile", limit=50, include_favourites=True
):
"""Собирает карту {chatId: [messages...]} для bootstrap-pre-fetch в LOGIN.
Десктопный MAX в ответе LOGIN ждёт поле `messages` как карту чат→история.
Если карта пустая — клиент полагает, что у него уже есть локальная
история и НЕ запрашивает CHAT_HISTORY (49). В итоге в окне чата
видно только lastMessage из chats[].
"""
result = {}
async def _fetch(chat_db_id, key_for_client):
async with db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute(
"SELECT * FROM `messages` WHERE chat_id = %s ORDER BY time DESC LIMIT %s",
(chat_db_id, limit),
)
rows = await cursor.fetchall()
if not rows:
return
messages = [self.build_message_dict(row, protocol_type) for row in rows]
messages.sort(key=lambda m: m["time"])
result[key_for_client] = messages
for chatId in chatIds:
await _fetch(chatId, chatId)
if include_favourites:
# Избранное: в БД хранится как chat_id = -senderId,
# но клиенту отдаётся под id = senderId ^ senderId (= 0)
await _fetch(-senderId, senderId ^ senderId)
return result
async def get_last_message(self, chatId, db_pool, protocol_type="mobile"):
"""Получение последнего сообщения в чате"""
async with db_pool.acquire() as db_connection:
@@ -412,35 +495,8 @@ class Tools:
if not row:
return None, None
message = {
"sender": row.get("sender"),
"id": row.get("id")
if protocol_type == "mobile"
else str(row.get("id")),
"time": int(row.get("time")),
"text": row.get("text"),
"type": row.get("type"),
"attaches": json.loads(row.get("attaches"))
}
elements = json.loads(row.get("elements"))
link = {}
reaction_info = {}
if elements:
message["elements"] = elements
if link:
message["link"] = link
if reaction_info:
message["reactionInfo"] = reaction_info
if protocol_type == "web":
message["cid"] = int(row.get("cid"))
# Возвращаем
return message, int(row.get("time"))
return self.build_message_dict(row, protocol_type), int(row.get("time"))
async def get_previous_message_id(self, chatId, db_pool, protocol_type="mobile"):
"""Получение ID предыдущего сообщения (второго с конца) в чате."""

View File

@@ -1,5 +1,6 @@
import pydantic
import json
import time
from classes.baseprocessor import BaseProcessor
from oneme.models import ChatHistoryPayloadModel
@@ -20,7 +21,10 @@ class HistoryProcessors(BaseProcessor):
backward = payload.get("backward", 0)
from_time = payload.get("from", 0)
getMessages = payload.get("getMessages", True)
getChat = payload.get("getChat", False)
messages = []
backward_count = 0
forward_count = 0
# Если пользователь хочет получить историю из избранного,
# то выставляем в качестве ID чата отрицательный ID отправителя
@@ -59,35 +63,8 @@ class HistoryProcessors(BaseProcessor):
result = await cursor.fetchall()
for row in result:
# TODO: Сборку тела сообщения нужно вынести в отдельную функцию
message = {
"sender": row.get("sender"),
"id": row.get("id")
if self.type == "mobile"
else str(row.get("id")),
"time": int(row.get("time")),
"text": row.get("text"),
"type": row.get("type"),
"attaches": json.loads(row.get("attaches"))
}
elements = json.loads(row.get("elements"))
link = {}
reaction_info = {}
if elements:
message["elements"] = elements
if link:
message["link"] = link
if reaction_info:
message["reactionInfo"] = reaction_info
if self.type == "web":
message["cid"] = int(row.get("cid"))
messages.append(message)
messages.append(self.tools.build_message_dict(row, self.type))
backward_count = len(result)
if forward > 0:
await cursor.execute(
"SELECT * FROM messages WHERE chat_id = %s AND time > %s ORDER BY time ASC LIMIT %s",
@@ -97,43 +74,40 @@ class HistoryProcessors(BaseProcessor):
result = await cursor.fetchall()
for row in result:
# TODO: Сборку тела сообщения нужно вынести в отдельную функцию
message = {
"sender": row.get("sender"),
"id": row.get("id")
if self.type == "mobile"
else str(row.get("id")),
"time": int(row.get("time")),
"text": row.get("text"),
"type": row.get("type"),
"attaches": json.loads(row.get("attaches"))
}
elements = json.loads(row.get("elements"))
link = {}
reaction_info = {}
if elements:
message["elements"] = elements
if link:
message["link"] = link
if reaction_info:
message["reactionInfo"] = reaction_info
if self.type == "web":
message["cid"] = int(row.get("cid"))
messages.append(message)
messages.append(self.tools.build_message_dict(row, self.type))
forward_count = len(result)
# Сортируем сообщения по времени
messages.sort(key=lambda x: x["time"])
# Формируем ответ
# КОСТЫЛЬ: клиент MAX в fz2.b() фильтрует сообщения по условию
# `message.time >= chat.createTime`. Если у пользователя чат был
# создан недавно, а наши сообщения в БД старые — все они отбрасываются
# (см. реверс defpackage.fz2.java:89). Сдвигаем time всех сообщений
# в «сейчас + N мс» — гарантированно > chat.createTime, и шаг по 1мс
# сохраняет порядок сортировки.
if messages:
now_ms = int(time.time() * 1000)
for i, m in enumerate(messages):
m["time"] = now_ms + i # на 1мс позже предыдущего
m["updateTime"] = m["time"]
# Формируем ответ.
# Реальный парсер ответа CHAT_HISTORY в MAX 26.15.x — это az2.j(),
# который ждёт всего 3 поля:
# chat — qs2-объект чата (опционально, если getChat=False)
# messages — массив сообщений (jr4.a → u6h.Q для каждого)
# messageIds — Set<Long> списка id сообщений в этом ответе
# Поля forward/backward/pos/total — это парсер a23 для CHAT_MEDIA,
# к chat_history они не имеют отношения.
payload = {
"messages": messages
"messages": messages,
"messageIds": [m["id"] for m in messages],
}
# chat-объект отдаём только если запрошен (getChat=True). Пустой
# qs2-dict рискует свалить парсер qs2.e() — лучше вообще не слать.
if getChat:
payload["chat"] = {}
# Собираем пакет
packet = self.proto.pack_packet(

View File

@@ -125,16 +125,21 @@ class MessagesProcessors(BaseProcessor):
db_pool=self.db_pool
)
# Готовое тело сообщения
# Готовое тело сообщения. Поля cid / elements / reactionInfo / link
# должны присутствовать ВСЕГДА (даже пустые) — десктопный MAX
# ожидает фиксированную msgpack-схему и обрывает соединение
# при отсутствии любого из них (см. регрессию из 87cfc19).
bodyMessage = {
"id": messageId,
"id": messageId if self.type == "mobile" else str(messageId),
"cid": int(cid or 0),
"time": messageTime,
"type": "USER",
"sender": senderId,
"cid": cid,
"text": text,
"attaches": attaches,
"elements": elements
"attaches": attaches if isinstance(attaches, list) else [],
"elements": elements if isinstance(elements, list) else [],
"reactionInfo": {},
"link": {},
}
# Отправляем событие всем участникам чата