Switch server bridge to PyMax

This commit is contained in:
sevenhill
2026-07-07 00:05:47 +03:00
parent 81b20dcaec
commit 8efeff334a
4 changed files with 275 additions and 27 deletions
+2 -2
View File
@@ -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
+271 -23
View File
@@ -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")},
)
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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,