From e59ca21c2a29bf5bf24ceb8e07d50df17f5a009e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Tue, 21 Jul 2026 15:53:32 +0300 Subject: [PATCH] Handle incoming MAX call events --- android/app/build.gradle.kts | 4 ++-- .../main/java/xyz/kusoft/qmax/MainActivity.kt | 22 ++++++++++++++++--- .../qmax/core/local/LocalMessageCache.kt | 6 +++++ pymax-worker/src/message_labels.py | 13 +++++++++++ pymax-worker/src/server.py | 14 ++++++++++-- pymax-worker/tests/test_message_labels.py | 22 +++++++++++++++++++ .../QMax.Api/Services/MessageTextSanitizer.cs | 19 ++++++++++++++++ tests/QMax.Tests/ApiSmokeTests.cs | 15 +++++++++++++ 8 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 pymax-worker/src/message_labels.py create mode 100644 pymax-worker/tests/test_message_labels.py diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f6ee741..4917784 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "xyz.kusoft.qmax" minSdk = 26 targetSdk = 36 - versionCode = 58 - versionName = "1.0.1" + versionCode = 59 + versionName = "1.0.2" buildConfigField("String", "QMAX_DEFAULT_SERVER_URL", "\"https://qmax.kusoft.xyz\"") buildConfigField("String", "QMAX_DEFAULT_PAIRING_CODE", "\"qmax-MxRq4h2HQBEIFs6k\"") diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 933d820..6e6ef77 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -3527,6 +3527,7 @@ private fun ChatRow( private fun cleanChatPreview(value: String?): String? { return when { + isCallMediaLabel(value) -> "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a" isGenericMediaPreview(value) -> "\u041c\u0435\u0434\u0438\u0430" isQuestionMarkArtifact(value) -> "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435" else -> value @@ -4076,6 +4077,21 @@ private fun ChatScreen(vm: QMaxViewModel) { } } +private fun isCallMediaLabel(value: String?): Boolean { + val text = value?.trim().orEmpty() + return text.equals("call", ignoreCase = true) || text.startsWith("call-", ignoreCase = true) +} + +private fun displayMessageText(value: String?): String? { + return value?.takeIf { it.isNotBlank() }?.let { text -> + if (isCallMediaLabel(text)) { + "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a" + } else { + text + } + } +} + @Composable private fun IncomingShareDialog( share: IncomingShare, @@ -4842,7 +4858,7 @@ private fun MessageRow( ) } } - message.text?.takeIf { it.isNotBlank() }?.let { text -> + displayMessageText(message.text)?.let { text -> LinkifiedMessageText( text = text, modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp) @@ -5104,7 +5120,7 @@ private fun replyPreviewAuthor(message: MessageDto): String { } private fun replyPreviewBody(message: MessageDto): String { - message.text?.takeIf { it.isNotBlank() }?.let { return it } + displayMessageText(message.text)?.let { return it } val attachment = message.attachments.firstOrNull() ?: return "Message" return when { attachment.isEmojiAttachment() -> "Emoji" @@ -6400,7 +6416,7 @@ private fun messageSortMillis(value: String): Long { private fun MessageDto.matchesSearch(query: String): Boolean { val needle = query.trim().lowercase(Locale.ROOT) if (needle.isBlank()) return false - return text?.lowercase(Locale.ROOT)?.contains(needle) == true || + return displayMessageText(text)?.lowercase(Locale.ROOT)?.contains(needle) == true || senderName?.lowercase(Locale.ROOT)?.contains(needle) == true || attachments.any { it.fileName.lowercase(Locale.ROOT).contains(needle) } } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt index 2e2cca5..2343e55 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/local/LocalMessageCache.kt @@ -124,12 +124,18 @@ class LocalMessageCache(context: Context) { private fun cleanChat(chat: ChatDto): ChatDto { return when { + isCallMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a") isGenericMediaLabel(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u041c\u0435\u0434\u0438\u0430") isQuestionMarkArtifact(chat.lastMessagePreview) -> chat.copy(lastMessagePreview = "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435") else -> chat } } + private fun isCallMediaLabel(value: String?): Boolean { + val text = value?.trim().orEmpty() + return text.equals("call", ignoreCase = true) || text.startsWith("call-", ignoreCase = true) + } + private fun isGenericMediaPlaceholder(message: MessageDto): Boolean { return message.attachments.isEmpty() && isGenericMediaLabel(message.text) } diff --git a/pymax-worker/src/message_labels.py b/pymax-worker/src/message_labels.py new file mode 100644 index 0000000..710a2cf --- /dev/null +++ b/pymax-worker/src/message_labels.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any + + +INCOMING_CALL_TEXT = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a" + + +def is_call_media_label(value: Any) -> bool: + if value is None: + return False + text = str(value).strip().lower() + return text == "call" or text.startswith("call-") diff --git a/pymax-worker/src/server.py b/pymax-worker/src/server.py index 8fc9aa7..e6c0c2b 100644 --- a/pymax-worker/src/server.py +++ b/pymax-worker/src/server.py @@ -22,6 +22,8 @@ from aiohttp import web from pymax import Client, ExtraConfig, File, Photo, SyncOverrides, Video from pymax.types import ContactInfo +from .message_labels import INCOMING_CALL_TEXT, is_call_media_label + PORT = int(os.environ.get("PORT", "3002")) PHONE_NUMBER = os.environ.get("PYMAX_PHONE_NUMBER") or os.environ.get("QMAX_MAX_PHONE_NUMBER") or "" @@ -581,6 +583,8 @@ def normalize_attachment_kind(att: Any) -> str: raw_type = value_name(data.get("type") or data.get("_type") or getattr(att, "type", "")).lower() class_name = type(att).__name__.lower() merged = f"{raw_type} {class_name}" + if is_call_media_label(raw_type) or "callattachment" in class_name: + return "call" if "photo" in merged: return "photo" if "video" in merged: @@ -600,6 +604,8 @@ def attachment_preview(attaches: list[Any]) -> str | None: if not attaches: return None kinds = [normalize_attachment_kind(att) for att in attaches] + if any(is_call_media_label(kind) for kind in kinds): + return INCOMING_CALL_TEXT if any(kind == "photo" for kind in kinds): return "\u0424\u043e\u0442\u043e" if any(kind == "video" for kind in kinds): @@ -731,16 +737,20 @@ async def normalize_message( me_id: int | None = None, ) -> dict[str, Any]: attaches = list(getattr(message, "attaches", None) or []) + call_attaches = [att for att in attaches if normalize_attachment_kind(att) == "call"] + downloadable_attaches = [att for att in attaches if normalize_attachment_kind(att) != "call"] attachments = [ await normalize_attachment(client, message, att, index) - for index, att in enumerate(attaches) + for index, att in enumerate(downloadable_attaches) ] sender = getattr(message, "sender", None) 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) + raw_text = getattr(message, "text", None) + is_call_text = is_call_media_label(raw_text) + text = INCOMING_CALL_TEXT if call_attaches or is_call_text else raw_text 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) diff --git a/pymax-worker/tests/test_message_labels.py b/pymax-worker/tests/test_message_labels.py new file mode 100644 index 0000000..7a0c930 --- /dev/null +++ b/pymax-worker/tests/test_message_labels.py @@ -0,0 +1,22 @@ +import unittest + +from src.message_labels import INCOMING_CALL_TEXT, is_call_media_label + + +class MessageLabelsTests(unittest.TestCase): + def test_call_media_labels_are_detected(self) -> None: + for value in ("call", "call-start", "CALL-MISSED", " call-ended "): + with self.subTest(value=value): + self.assertTrue(is_call_media_label(value)) + + def test_unrelated_values_are_not_detected(self) -> None: + for value in (None, "", "callback", "video-call", "phone"): + with self.subTest(value=value): + self.assertFalse(is_call_media_label(value)) + + def test_incoming_call_text_is_stable(self) -> None: + self.assertEqual("\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a", INCOMING_CALL_TEXT) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/QMax.Api/Services/MessageTextSanitizer.cs b/server/QMax.Api/Services/MessageTextSanitizer.cs index 5bca3e5..fb102a2 100644 --- a/server/QMax.Api/Services/MessageTextSanitizer.cs +++ b/server/QMax.Api/Services/MessageTextSanitizer.cs @@ -2,8 +2,15 @@ namespace QMax.Api.Services; public static class MessageTextSanitizer { + private const string IncomingCallText = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a"; + public static string? CleanChatPreview(string? value) { + if (IsCallMediaLabel(value)) + { + return IncomingCallText; + } + if (IsGenericMediaLabel(value)) { return "\u041c\u0435\u0434\u0438\u0430"; @@ -16,6 +23,11 @@ public static class MessageTextSanitizer public static string? CleanMessageText(string? value, bool hasAttachments) { + if (IsCallMediaLabel(value)) + { + return IncomingCallText; + } + if (hasAttachments && IsGenericAttachmentLabel(value)) { return null; @@ -60,6 +72,13 @@ public static class MessageTextSanitizer }; } + public static bool IsCallMediaLabel(string? value) + { + var text = value?.Trim(); + return string.Equals(text, "call", StringComparison.OrdinalIgnoreCase) || + text?.StartsWith("call-", StringComparison.OrdinalIgnoreCase) == true; + } + public static bool IsGenericAttachmentLabel(string? value) { if (IsGenericMediaLabel(value)) diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 6b430be..40ac537 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -1676,6 +1676,21 @@ public sealed class ApiSmokeTests : IDisposable Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????")); } + [Theory] + [InlineData("call-start")] + [InlineData("CALL-MISSED")] + [InlineData(" call-ended ")] + [InlineData("call")] + public void MessageTextSanitizerLabelsCallMediaAsIncomingCall(string value) + { + const string expected = "\u0412\u0445\u043e\u0434\u044f\u0449\u0438\u0439 \u0437\u0432\u043e\u043d\u043e\u043a"; + + Assert.True(MessageTextSanitizer.IsCallMediaLabel(value)); + Assert.Equal(expected, MessageTextSanitizer.CleanChatPreview(value)); + Assert.Equal(expected, MessageTextSanitizer.CleanMessageText(value, hasAttachments: false)); + Assert.Equal(expected, MessageTextSanitizer.CleanMessageText(value, hasAttachments: true)); + } + [Fact] public async Task SyncDoesNotPushListPreviewEchoOfOwnOutgoingMessage() {