MAX & TT: теперь полноценный чёрный список

This commit is contained in:
Alexey Polyakov
2026-05-13 15:58:02 +03:00
parent 7d2e070d1f
commit 87f22a3feb
7 changed files with 53 additions and 90 deletions

View File

@@ -15,6 +15,7 @@ class Static:
RATE_LIMITED = "rate_limited" RATE_LIMITED = "rate_limited"
CONTACT_NOT_FOUND = "contact_not_found" CONTACT_NOT_FOUND = "contact_not_found"
CONTACT_ALREADY_ADDED = "contact_already_added" CONTACT_ALREADY_ADDED = "contact_already_added"
CONTACT_BLOCKED = "contact_blocked"
class ChatTypes: class ChatTypes:
DIALOG = "DIALOG" DIALOG = "DIALOG"
@@ -95,6 +96,12 @@ class Static:
"message": "Contact already added", "message": "Contact already added",
"title": "Контакт уже добавлен" "title": "Контакт уже добавлен"
}, },
"contact_blocked": {
"localizedMessage": "Вы не можете написать этому пользователю",
"error": "contact.blocked",
"message": "Contact is blocked",
"title": "Вы не можете написать этому пользователю"
},
} }
### Сообщения бота ### Сообщения бота

View File

@@ -11,14 +11,7 @@ class Tools:
pass pass
def build_message_dict(self, row, protocol_type="mobile"): def build_message_dict(self, row, protocol_type="mobile"):
"""Унифицированная сборка тела сообщения для отправки клиенту. """Сборка тела сообщения"""
Десктоп MAX (TCP, protocol_type='mobile') и официальный
api.oneme.ru ожидают, что в сообщении будут ВСЕГДА присутствовать
поля cid / elements / link / reactionInfo, даже если они пустые.
Любое отсутствие поля приводит к тому, что клиент бросает соединение
при разборе msgpack-схемы (классическая регрессия из коммита 87cfc19).
"""
try: try:
attaches = json.loads(row.get("attaches") or "[]") attaches = json.loads(row.get("attaches") or "[]")
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -28,29 +21,18 @@ class Tools:
except (TypeError, ValueError): except (TypeError, ValueError):
elements = [] 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 = { message = {
"id": row.get("id") if protocol_type == "mobile" else str(row.get("id")), "id": row.get("id") if protocol_type == "mobile" else str(row.get("id")),
"cid": int(row.get("cid") or 0), "cid": int(row.get("cid") or 0),
"chatId": int(row.get("chat_id") or 0), "chatId": int(row.get("chat_id") or 0),
"time": int(row.get("time")), "time": int(row.get("time")),
"type": row.get("type") or "USER", # ENUM-строка: USER/CHANNEL/CHANNEL_ADMIN/GROUP "type": row.get("type") or "USER",
"sender": row.get("sender"), "sender": row.get("sender"),
"text": row.get("text") or "", "text": row.get("text") or "",
"attaches": attaches if isinstance(attaches, list) else [], "attaches": attaches,
"elements": elements if isinstance(elements, list) else [], "elements": elements,
"reactionInfo": {}, "reactionInfo": {},
"link": {}, "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 return message
@@ -441,44 +423,6 @@ class Tools:
# Возвращаем айдишки # Возвращаем айдишки
return int(message_id), int(last_message_id), message_time 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 def get_last_message(self, chatId, db_pool, protocol_type="mobile"):
"""Получение последнего сообщения в чате""" """Получение последнего сообщения в чате"""
async with db_pool.acquire() as db_connection: async with db_pool.acquire() as db_connection:
@@ -645,3 +589,23 @@ class Tools:
await cursor.execute("SELECT id FROM users WHERE id = %s", (user_id,)) await cursor.execute("SELECT id FROM users WHERE id = %s", (user_id,))
if not await cursor.fetchone(): if not await cursor.fetchone():
return user_id return user_id
async def contact_is_blocked(self, owner_id, contact_id, db_pool):
"""
По изначальной задумке, данная функция должна проверять, заблокирован ли контакт
На сервере долгое время не был доделан черный список, хотя управление им было реализовано
(на деле, это я поленился)
Вернёт вам true, если контакт заблокирован, иначе false
"""
# Проверяем наличие контакта
async with db_pool.acquire() as conn:
async with conn.cursor() as cursor:
await cursor.execute("SELECT * FROM contacts WHERE owner_id = %s AND contact_id = %s AND is_blocked = %s", (owner_id, contact_id, True))
row = await cursor.fetchone()
# Есди контакт существует и заблокирован, возвращаем true,
if row:
return True
else: # в ином случае false
return False

View File

@@ -2,7 +2,7 @@ from .assets import AssetsProcessors
from .auth import AuthProcessors from .auth import AuthProcessors
from .calls import CallsProcessors from .calls import CallsProcessors
from .chats import ChatsProcessors from .chats import ChatsProcessors
from .complains import ComplainsProcessors from .complaints import ComplaintsProcessors
from .contacts import ContactsProcessors from .contacts import ContactsProcessors
from .folders import FoldersProcessors from .folders import FoldersProcessors
from .history import HistoryProcessors from .history import HistoryProcessors
@@ -16,7 +16,7 @@ class Processors(
AuthProcessors, AuthProcessors,
CallsProcessors, CallsProcessors,
ChatsProcessors, ChatsProcessors,
ComplainsProcessors, ComplaintsProcessors,
ContactsProcessors, ContactsProcessors,
FoldersProcessors, FoldersProcessors,
HistoryProcessors, HistoryProcessors,

View File

@@ -3,7 +3,7 @@ import time
from classes.baseprocessor import BaseProcessor from classes.baseprocessor import BaseProcessor
from oneme.models import ComplainReasonsGetPayloadModel from oneme.models import ComplainReasonsGetPayloadModel
class ComplainsProcessors(BaseProcessor): class ComplaintsProcessors(BaseProcessor):
async def complain_reasons_get(self, payload, seq, writer): async def complain_reasons_get(self, payload, seq, writer):
"""Обработчик получения причин жалоб""" """Обработчик получения причин жалоб"""
# Валидируем данные пакета # Валидируем данные пакета

View File

@@ -23,8 +23,6 @@ class HistoryProcessors(BaseProcessor):
getMessages = payload.get("getMessages", True) getMessages = payload.get("getMessages", True)
getChat = payload.get("getChat", False) getChat = payload.get("getChat", False)
messages = [] messages = []
backward_count = 0
forward_count = 0
# Если пользователь хочет получить историю из избранного, # Если пользователь хочет получить историю из избранного,
# то выставляем в качестве ID чата отрицательный ID отправителя # то выставляем в качестве ID чата отрицательный ID отправителя
@@ -80,32 +78,10 @@ class HistoryProcessors(BaseProcessor):
# Сортируем сообщения по времени # Сортируем сообщения по времени
messages.sort(key=lambda x: x["time"]) 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 = { payload = {
"messages": messages, "messages": messages
"messageIds": [m["id"] for m in messages],
} }
# chat-объект отдаём только если запрошен (getChat=True). Пустой
# qs2-dict рискует свалить парсер qs2.e() — лучше вообще не слать.
if getChat: if getChat:
payload["chat"] = {} payload["chat"] = {}

View File

@@ -113,6 +113,14 @@ class MessagesProcessors(BaseProcessor):
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer) await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer)
return return
# Проверяем блокировку собеседника
if chat.get("type") == "DIALOG":
contactid = [p for p in participants if p != int(senderId)][0]
# Проверяем, заблокировал ли отправитель собеседника
if await self.tools.contact_is_blocked(contactid, senderId, db_pool):
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CONTACT_BLOCKED, writer)
return
# Добавляем сообщение в историю # Добавляем сообщение в историю
messageId, lastMessageId, messageTime = await self.tools.insert_message( messageId, lastMessageId, messageTime = await self.tools.insert_message(
chatId=chatId, chatId=chatId,

View File

@@ -105,6 +105,14 @@ class MessagesProcessors(BaseProcessor):
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer) await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CHAT_NOT_ACCESS, writer)
return return
# Проверяем блокировку собеседника
if chat.get("type") == "DIALOG":
contactid = [p for p in participants if p != int(senderId)][0]
# Проверяем, заблокировал ли отправитель собеседника
if await self.tools.contact_is_blocked(contactid, senderId, db_pool):
await self._send_error(seq, self.opcodes.MSG_SEND, self.error_types.CONTACT_BLOCKED, writer)
return
# Добавляем сообщение в историю # Добавляем сообщение в историю
messageId, lastMessageId, messageTime = await self.tools.insert_message( messageId, lastMessageId, messageTime = await self.tools.insert_message(
chatId=chatId, chatId=chatId,