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__PairingCode: ${QMAX_PAIRING_CODE}
QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER} QMax__MaxPhoneNumber: ${QMAX_MAX_PHONE_NUMBER}
QMax__MaxMode: Worker QMax__MaxMode: Worker
QMax__MaxWorkerBaseUrl: http://qmax-max-worker:3001 QMax__MaxWorkerBaseUrl: http://qmax-pymax-worker:3002
QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-} QMax__CorsAllowedOrigins: ${QMAX_CORS_ALLOWED_ORIGINS:-}
QMax__PushEnabled: ${QMAX_PUSH_ENABLED:-true} QMax__PushEnabled: ${QMAX_PUSH_ENABLED:-true}
QMax__PushShowPreview: ${QMAX_PUSH_SHOW_PREVIEW:-true} QMax__PushShowPreview: ${QMAX_PUSH_SHOW_PREVIEW:-true}
@@ -28,7 +28,7 @@ services:
ports: ports:
- "127.0.0.1:18080:8080" - "127.0.0.1:18080:8080"
depends_on: depends_on:
- qmax-max-worker - qmax-pymax-worker
networks: networks:
- qmax - qmax
+271 -23
View File
@@ -8,6 +8,7 @@ import pathlib
import stat import stat
import time import time
import traceback import traceback
import urllib.parse
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from typing import Any
@@ -84,6 +85,169 @@ def value_name(value: Any) -> str:
return str(raw) if raw is not None else "" 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: class DeferredCodeProvider:
def __init__(self) -> None: def __init__(self) -> None:
self._future: asyncio.Future[str] | None = 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" 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]: async def normalize_attachment(client: Client, message: Any, att: Any, sort_order: int) -> dict[str, Any]:
data = dump_model(att) data = dump_model(att)
kind = normalize_attachment_kind(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 = ( external_id = (
data.get("photo_id") data.get("photo_id")
or data.get("video_id") or data.get("video_id")
or data.get("file_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("id")
or data.get("token") or data.get("token")
or f"{clean_id(getattr(message, 'id', None))}:{sort_order}" or f"{clean_id(getattr(message, 'id', None))}:{sort_order}"
) )
fallback_extension = extension_from_url(remote_url)
file_name = ( file_name = (
data.get("name") data.get("name")
or data.get("filename") or data.get("filename")
or data.get("file_name") 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 content_type = None
file_size = data.get("size") or data.get("file_size") or data.get("fileSize") 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" content_type = "video/mp4"
elif kind == "audio": elif kind == "audio":
content_type = "audio/ogg" 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: elif file_name:
content_type = mimetypes.guess_type(str(file_name))[0] content_type = mimetypes.guess_type(str(file_name))[0]
text_content = None text_content = None
if kind in {"sticker", "contact"}: if kind == "contact":
text_content = str(data) text_content = contact_to_vcard(data)
return { return {
"externalId": clean_id(external_id), "externalId": clean_id(external_id),
@@ -342,33 +569,37 @@ async def normalize_attachment(client: Client, message: Any, att: Any, sort_orde
"contentType": content_type, "contentType": content_type,
"fileSizeBytes": file_size, "fileSizeBytes": file_size,
"remoteUrl": remote_url, "remoteUrl": remote_url,
"kind": kind, "kind": attachment_api_kind(kind),
"sortOrder": sort_order, "sortOrder": sort_order,
"textContent": text_content, "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 []) attaches = list(getattr(message, "attaches", None) or [])
attachments = [ attachments = [
await normalize_attachment(client, message, att, index) await normalize_attachment(client, message, att, index)
for index, att in enumerate(attaches) for index, att in enumerate(attaches)
] ]
sender = getattr(message, "sender", None) sender = getattr(message, "sender", None)
me_id = None sender_id = coerce_int(sender)
me = getattr(client, "me", None) if me_id is None:
contact = getattr(me, "contact", None) me_id = get_me_user_id(client)
if contact is not None: is_outgoing = sender_id is not None and me_id is not None and sender_id == me_id
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)
text = getattr(message, "text", None) or attachment_preview(attaches) text = getattr(message, "text", None) or attachment_preview(attaches)
status = value_name(getattr(message, "status", None)).lower() or None 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 { return {
"externalId": clean_id(getattr(message, "id", None)), "externalId": clean_id(getattr(message, "id", None)),
"senderExternalId": clean_id(sender) or None, "senderExternalId": clean_id(sender) or None,
"senderName": None, "senderName": sender_name,
"isOutgoing": is_outgoing, "isOutgoing": is_outgoing,
"text": text, "text": text,
"sentAt": millis_to_iso(getattr(message, "time", None)), "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) last_message = getattr(chat, "last_message", None)
raw_messages: list[Any] = []
messages: list[dict[str, Any]] = [] messages: list[dict[str, Any]] = []
if include_history: if include_history:
history = await client.fetch_history(chat_id=int(chat.id), backward=HISTORY_LIMIT) 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: 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_preview = None
last_at = getattr(chat, "last_event_time", 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_at = int(datetime.fromisoformat(last["sentAt"]).timestamp() * 1000)
last_is_outgoing = last.get("isOutgoing") last_is_outgoing = last.get("isOutgoing")
title = getattr(chat, "title", None) or f"MAX {chat.id}"
return { return {
"externalId": clean_id(getattr(chat, "id", None)), "externalId": clean_id(getattr(chat, "id", None)),
"title": title, "title": resolve_chat_title(chat, user_map, me_id),
"avatarUrl": getattr(chat, "base_icon_url", None) or getattr(chat, "base_raw_icon_url", None), "avatarUrl": resolve_chat_avatar(chat, user_map, me_id),
"updatedAt": millis_to_iso(last_at), "updatedAt": millis_to_iso(last_at),
"messages": messages, "messages": messages,
"lastMessagePreview": last_preview, "lastMessagePreview": last_preview,
@@ -532,7 +775,12 @@ async def snapshot(_request: web.Request) -> web.Response:
async def updates(_request: web.Request) -> web.Response: async def updates(_request: web.Request) -> web.Response:
client = await runtime.get_client() client = await runtime.get_client()
chats = await fetch_chat_pages(client, CHAT_FETCH_LIMIT) 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) return json_response(result)
@@ -598,7 +846,7 @@ async def media_fetch(request: web.Request) -> web.StreamResponse:
body = await response.read() body = await response.read()
return web.Response( return web.Response(
body=body, 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 PairingCode { get; set; } = "";
public string MaxPhoneNumber { get; set; } = ""; public string MaxPhoneNumber { get; set; } = "";
public string MaxMode { get; set; } = "Worker"; 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 string MaxUserDataPath { get; set; } = "data/max-profile";
public bool MaxHeadless { get; set; } = true; public bool MaxHeadless { get; set; } = true;
public int MaxPollIntervalSeconds { get; set; } = 6; public int MaxPollIntervalSeconds { get; set; } = 6;
+1 -1
View File
@@ -16,7 +16,7 @@
"PairingCode": "", "PairingCode": "",
"MaxPhoneNumber": "", "MaxPhoneNumber": "",
"MaxMode": "Worker", "MaxMode": "Worker",
"MaxWorkerBaseUrl": "http://qmax-max-worker:3001", "MaxWorkerBaseUrl": "http://qmax-pymax-worker:3002",
"MaxUserDataPath": "data/max-profile", "MaxUserDataPath": "data/max-profile",
"MaxHeadless": true, "MaxHeadless": true,
"MaxPollIntervalSeconds": 6, "MaxPollIntervalSeconds": 6,