Switch server bridge to PyMax
This commit is contained in:
@@ -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
@@ -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")},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user