From 8efeff334a96b1741be64ae321cd88ee1f4a9566 Mon Sep 17 00:00:00 2001 From: sevenhill Date: Tue, 7 Jul 2026 00:05:47 +0300 Subject: [PATCH] Switch server bridge to PyMax --- deploy/docker-compose.yml | 4 +- pymax-worker/src/server.py | 294 +++++++++++++++++-- server/QMax.Api/Configuration/QMaxOptions.cs | 2 +- server/QMax.Api/appsettings.json | 2 +- 4 files changed, 275 insertions(+), 27 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 53c38e5..77b91d3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -15,7 +15,7 @@ services: QMax__PairingCode: ${QMAX_PAIRING_CODE} QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER} QMax__MaxMode: Worker - QMax__MaxWorkerBaseUrl: http://qmax-max-worker:3001 + QMax__MaxWorkerBaseUrl: http://qmax-pymax-worker:3002 QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-} QMax__PushEnabled: ${QMAX_PUSH_ENABLED:-true} QMax__PushShowPreview: ${QMAX_PUSH_SHOW_PREVIEW:-true} @@ -28,7 +28,7 @@ services: ports: - "127.0.0.1:18080:8080" depends_on: - - qmax-max-worker + - qmax-pymax-worker networks: - qmax diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py index 0bc1218..1745a0a 100644 --- a/pymax-worker/src/server.py +++ b/pymax-worker/src/server.py @@ -8,6 +8,7 @@ import pathlib import stat import time import traceback +import urllib.parse from collections.abc import Awaitable, Callable from datetime import UTC, datetime from typing import Any @@ -84,6 +85,169 @@ def value_name(value: Any) -> str: return str(raw) if raw is not None else "" +def coerce_int(value: Any) -> int | None: + if value is None: + return None + try: + return int(str(value)) + except (TypeError, ValueError): + return None + + +def first_text(*values: Any) -> str | None: + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return None + + +def unique_ints(values: list[int | None]) -> list[int]: + seen: set[int] = set() + result: list[int] = [] + for value in values: + if value is None or value in seen: + continue + seen.add(value) + result.append(value) + return result + + +def find_first_key(value: Any, keys: set[str]) -> Any: + if isinstance(value, dict): + for key in keys: + if value.get(key): + return value[key] + for item in value.values(): + found = find_first_key(item, keys) + if found: + return found + elif isinstance(value, list): + for item in value: + found = find_first_key(item, keys) + if found: + return found + return None + + +def extension_from_url(url: str | None) -> str: + if not url: + return "" + parsed = urllib.parse.urlparse(url) + suffix = pathlib.Path(parsed.path).suffix.lower() + return suffix if 0 < len(suffix) <= 16 else "" + + +def get_me_user_id(client: Client) -> int | None: + me = getattr(client, "me", None) + data = dump_model(me) + contact = data.get("contact") if isinstance(data.get("contact"), dict) else {} + return coerce_int(contact.get("id") or data.get("id") or getattr(me, "id", None)) + + +def chat_participant_ids(chat: Any) -> list[int]: + data = dump_model(chat) + ids: list[int | None] = [coerce_int(data.get("owner"))] + participants = data.get("participants") + if isinstance(participants, dict): + ids.extend(coerce_int(key) for key in participants.keys()) + elif isinstance(participants, list): + for item in participants: + if isinstance(item, dict): + ids.append(coerce_int(item.get("user_id") or item.get("id") or item.get("contact_id"))) + else: + ids.append(coerce_int(item)) + return unique_ints(ids) + + +def message_sender_ids(messages: list[Any]) -> list[int]: + return unique_ints([coerce_int(getattr(message, "sender", None)) for message in messages]) + + +async def build_user_map(client: Client, chats: list[Any], messages: list[Any] | None = None) -> dict[str, Any]: + ids: list[int | None] = [] + for chat in chats: + ids.extend(chat_participant_ids(chat)) + if messages: + ids.extend(message_sender_ids(messages)) + + user_ids = [value for value in unique_ints(ids) if value > 0] + users: dict[str, Any] = {} + for offset in range(0, len(user_ids), 100): + batch = user_ids[offset:offset + 100] + if not batch: + continue + try: + fetched = await client.get_users(batch) + except Exception: # noqa: BLE001 + fetched = await client.fetch_users(batch) + for user in fetched or []: + user_id = coerce_int(dump_model(user).get("id") or getattr(user, "id", None)) + if user_id is not None: + users[str(user_id)] = user + return users + + +def user_display_name(user: Any) -> str | None: + data = dump_model(user) + names = data.get("names") if isinstance(data.get("names"), list) else [] + for preferred in ("CUSTOM", "ONEME"): + for name in names: + if not isinstance(name, dict): + continue + if str(name.get("type") or "").upper() == preferred: + value = first_text(name.get("name"), " ".join( + part for part in [str(name.get("first_name") or "").strip(), str(name.get("last_name") or "").strip()] + if part + )) + if value: + return value + for name in names: + if isinstance(name, dict): + value = first_text(name.get("name"), name.get("first_name")) + if value: + return value + value = first_text( + data.get("name"), + " ".join(part for part in [str(data.get("first_name") or "").strip(), str(data.get("last_name") or "").strip()] if part), + data.get("phone"), + ) + return value + + +def user_avatar_url(user: Any) -> str | None: + data = dump_model(user) + return first_text(data.get("base_url"), data.get("base_raw_url")) + + +def chat_other_user(chat: Any, user_map: dict[str, Any], me_id: int | None) -> Any | None: + for user_id in chat_participant_ids(chat): + if me_id is not None and user_id == me_id: + continue + user = user_map.get(str(user_id)) + if user is not None: + return user + return None + + +def resolve_chat_title(chat: Any, user_map: dict[str, Any], me_id: int | None) -> str: + title = first_text(getattr(chat, "title", None)) + if title: + return title + other = chat_other_user(chat, user_map, me_id) + return user_display_name(other) or f"MAX {chat.id}" + + +def resolve_chat_avatar(chat: Any, user_map: dict[str, Any], me_id: int | None) -> str | None: + chat_avatar = first_text(getattr(chat, "base_icon_url", None), getattr(chat, "base_raw_icon_url", None)) + if chat_avatar: + return chat_avatar + other = chat_other_user(chat, user_map, me_id) + return user_avatar_url(other) + + class DeferredCodeProvider: def __init__(self) -> None: self._future: asyncio.Future[str] | None = None @@ -288,24 +452,83 @@ def normalize_attachment_kind(att: Any) -> str: return raw_type or class_name or "attachment" +def attachment_preview(attaches: list[Any]) -> str | None: + if not attaches: + return None + kinds = [normalize_attachment_kind(att) for att in attaches] + if any(kind == "photo" for kind in kinds): + return "\u0424\u043e\u0442\u043e" + if any(kind == "video" for kind in kinds): + return "\u0412\u0438\u0434\u0435\u043e" + if any(kind == "audio" for kind in kinds): + return "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" + if any(kind == "sticker" for kind in kinds): + return "\u0421\u0442\u0438\u043a\u0435\u0440" + if any(kind == "contact" for kind in kinds): + return "\u041a\u043e\u043d\u0442\u0430\u043a\u0442" + return "\u0424\u0430\u0439\u043b" + + +def attachment_api_kind(kind: str) -> str: + return { + "photo": "Image", + "video": "Video", + "audio": "VoiceNote", + "sticker": "Sticker", + "contact": "Contact", + "file": "File", + }.get(kind, "File") + + +def contact_to_vcard(data: dict[str, Any]) -> str: + first_name = first_text(data.get("first_name"), data.get("firstName")) + last_name = first_text(data.get("last_name"), data.get("lastName")) + full_name = first_text(data.get("name"), data.get("full_name"), " ".join( + part for part in [first_name or "", last_name or ""] if part + )) + phone = first_text(data.get("phone"), data.get("phone_number"), data.get("phoneNumber")) + lines = ["BEGIN:VCARD", "VERSION:3.0"] + if full_name: + lines.append(f"FN:{full_name}") + if first_name or last_name: + lines.append(f"N:{last_name or ''};{first_name or ''};;;") + if phone: + lines.append(f"TEL;TYPE=CELL:{phone}") + lines.append("END:VCARD") + return "\r\n".join(lines) + "\r\n" + + async def normalize_attachment(client: Client, message: Any, att: Any, sort_order: int) -> dict[str, Any]: data = dump_model(att) kind = normalize_attachment_kind(att) + remote_url = first_text( + data.get("base_url"), + data.get("url"), + data.get("lottie_url"), + data.get("thumbnail"), + data.get("preview_url"), + find_first_key(data, {"base_url", "url", "lottie_url", "thumbnail", "preview_url"}), + ) external_id = ( data.get("photo_id") or data.get("video_id") or data.get("file_id") + or data.get("sticker_id") + or data.get("emoji_id") + or data.get("contact_id") or data.get("id") or data.get("token") or f"{clean_id(getattr(message, 'id', None))}:{sort_order}" ) + fallback_extension = extension_from_url(remote_url) file_name = ( data.get("name") or data.get("filename") or data.get("file_name") - or f"{kind}-{external_id}" + or f"{kind}-{external_id}{fallback_extension}" ) - remote_url = data.get("base_url") or data.get("url") or data.get("thumbnail") + if kind == "contact" and not pathlib.Path(str(file_name)).suffix: + file_name = f"{file_name}.vcf" content_type = None file_size = data.get("size") or data.get("file_size") or data.get("fileSize") @@ -329,12 +552,16 @@ async def normalize_attachment(client: Client, message: Any, att: Any, sort_orde content_type = "video/mp4" elif kind == "audio": content_type = "audio/ogg" + elif kind == "sticker": + content_type = mimetypes.guess_type(str(file_name))[0] or mimetypes.guess_type(remote_url or "")[0] or "image/webp" + elif kind == "contact": + content_type = "text/vcard; charset=utf-8" elif file_name: content_type = mimetypes.guess_type(str(file_name))[0] text_content = None - if kind in {"sticker", "contact"}: - text_content = str(data) + if kind == "contact": + text_content = contact_to_vcard(data) return { "externalId": clean_id(external_id), @@ -342,33 +569,37 @@ async def normalize_attachment(client: Client, message: Any, att: Any, sort_orde "contentType": content_type, "fileSizeBytes": file_size, "remoteUrl": remote_url, - "kind": kind, + "kind": attachment_api_kind(kind), "sortOrder": sort_order, "textContent": text_content, } -async def normalize_message(client: Client, chat: Any, message: Any) -> dict[str, Any]: +async def normalize_message( + client: Client, + chat: Any, + message: Any, + user_map: dict[str, Any] | None = None, + me_id: int | None = None, +) -> dict[str, Any]: attaches = list(getattr(message, "attaches", None) or []) attachments = [ await normalize_attachment(client, message, att, index) for index, att in enumerate(attaches) ] sender = getattr(message, "sender", None) - me_id = None - me = getattr(client, "me", None) - contact = getattr(me, "contact", None) - if contact is not None: - me_id = getattr(contact, "id", None) - if me_id is None and me is not None: - me_id = getattr(me, "id", None) - is_outgoing = sender is not None and me_id is not None and str(sender) == str(me_id) + sender_id = coerce_int(sender) + if me_id is None: + me_id = get_me_user_id(client) + is_outgoing = sender_id is not None and me_id is not None and sender_id == me_id text = getattr(message, "text", None) or attachment_preview(attaches) status = value_name(getattr(message, "status", None)).lower() or None + user = (user_map or {}).get(str(sender_id)) if sender_id is not None else None + sender_name = "You" if is_outgoing else user_display_name(user) return { "externalId": clean_id(getattr(message, "id", None)), "senderExternalId": clean_id(sender) or None, - "senderName": None, + "senderName": sender_name, "isOutgoing": is_outgoing, "text": text, "sentAt": millis_to_iso(getattr(message, "time", None)), @@ -377,14 +608,27 @@ async def normalize_message(client: Client, chat: Any, message: Any) -> dict[str } -async def normalize_chat_update(client: Client, chat: Any, include_history: bool = False) -> dict[str, Any]: +async def normalize_chat_update( + client: Client, + chat: Any, + include_history: bool = False, + user_map: dict[str, Any] | None = None, + me_id: int | None = None, +) -> dict[str, Any]: last_message = getattr(chat, "last_message", None) + raw_messages: list[Any] = [] messages: list[dict[str, Any]] = [] if include_history: history = await client.fetch_history(chat_id=int(chat.id), backward=HISTORY_LIMIT) - messages = [await normalize_message(client, chat, m) for m in (history or [])] + raw_messages = list(history or []) elif last_message is not None: - messages = [await normalize_message(client, chat, last_message)] + raw_messages = [last_message] + + if me_id is None: + me_id = get_me_user_id(client) + if user_map is None: + user_map = await build_user_map(client, [chat], raw_messages) + messages = [await normalize_message(client, chat, m, user_map, me_id) for m in raw_messages] last_preview = None last_at = getattr(chat, "last_event_time", None) @@ -395,11 +639,10 @@ async def normalize_chat_update(client: Client, chat: Any, include_history: bool last_at = int(datetime.fromisoformat(last["sentAt"]).timestamp() * 1000) last_is_outgoing = last.get("isOutgoing") - title = getattr(chat, "title", None) or f"MAX {chat.id}" return { "externalId": clean_id(getattr(chat, "id", None)), - "title": title, - "avatarUrl": getattr(chat, "base_icon_url", None) or getattr(chat, "base_raw_icon_url", None), + "title": resolve_chat_title(chat, user_map, me_id), + "avatarUrl": resolve_chat_avatar(chat, user_map, me_id), "updatedAt": millis_to_iso(last_at), "messages": messages, "lastMessagePreview": last_preview, @@ -532,7 +775,12 @@ async def snapshot(_request: web.Request) -> web.Response: async def updates(_request: web.Request) -> web.Response: client = await runtime.get_client() chats = await fetch_chat_pages(client, CHAT_FETCH_LIMIT) - result = [await normalize_chat_update(client, chat, include_history=False) for chat in chats] + me_id = get_me_user_id(client) + user_map = await build_user_map(client, chats) + result = [ + await normalize_chat_update(client, chat, include_history=False, user_map=user_map, me_id=me_id) + for chat in chats + ] return json_response(result) @@ -598,7 +846,7 @@ async def media_fetch(request: web.Request) -> web.StreamResponse: body = await response.read() return web.Response( body=body, - content_type=response.headers.get("Content-Type", "application/octet-stream"), + headers={"Content-Type": response.headers.get("Content-Type", "application/octet-stream")}, ) diff --git a/server/QMax.Api/Configuration/QMaxOptions.cs b/server/QMax.Api/Configuration/QMaxOptions.cs index 3884137..f8fa7cf 100644 --- a/server/QMax.Api/Configuration/QMaxOptions.cs +++ b/server/QMax.Api/Configuration/QMaxOptions.cs @@ -11,7 +11,7 @@ public sealed class QMaxOptions public string PairingCode { get; set; } = ""; public string MaxPhoneNumber { get; set; } = ""; public string MaxMode { get; set; } = "Worker"; - public string MaxWorkerBaseUrl { get; set; } = "http://qmax-max-worker:3001"; + public string MaxWorkerBaseUrl { get; set; } = "http://qmax-pymax-worker:3002"; public string MaxUserDataPath { get; set; } = "data/max-profile"; public bool MaxHeadless { get; set; } = true; public int MaxPollIntervalSeconds { get; set; } = 6; diff --git a/server/QMax.Api/appsettings.json b/server/QMax.Api/appsettings.json index 0d2f938..f9de6e5 100644 --- a/server/QMax.Api/appsettings.json +++ b/server/QMax.Api/appsettings.json @@ -16,7 +16,7 @@ "PairingCode": "", "MaxPhoneNumber": "", "MaxMode": "Worker", - "MaxWorkerBaseUrl": "http://qmax-max-worker:3001", + "MaxWorkerBaseUrl": "http://qmax-pymax-worker:3002", "MaxUserDataPath": "data/max-profile", "MaxHeadless": true, "MaxPollIntervalSeconds": 6,