From 3e3d1538eba0bbb20ee27be8450fb63934a4252c Mon Sep 17 00:00:00 2001 From: sevenhill Date: Sat, 4 Jul 2026 21:47:06 +0300 Subject: [PATCH] Fix emoji sending and self push notifications --- .../core/push/QMaxFirebaseMessagingService.kt | 4 + .../FirebasePushNotificationService.cs | 9 +- tests/QMax.Tests/ApiSmokeTests.cs | 64 +++++++ worker/src/server.js | 179 +++++++++++++++++- 4 files changed, 248 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/push/QMaxFirebaseMessagingService.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/push/QMaxFirebaseMessagingService.kt index 782c4a9..3b10c24 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/push/QMaxFirebaseMessagingService.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/push/QMaxFirebaseMessagingService.kt @@ -33,6 +33,10 @@ class QMaxFirebaseMessagingService : FirebaseMessagingService() { return } + if (message.data["direction"].equals("Outgoing", ignoreCase = true)) { + return + } + val title = message.notification?.title ?: message.data["title"] ?: message.data["chatTitle"] diff --git a/server/QMax.Api/Services/FirebasePushNotificationService.cs b/server/QMax.Api/Services/FirebasePushNotificationService.cs index c636ba0..0aadf5a 100644 --- a/server/QMax.Api/Services/FirebasePushNotificationService.cs +++ b/server/QMax.Api/Services/FirebasePushNotificationService.cs @@ -26,6 +26,12 @@ public sealed class FirebasePushNotificationService( public async Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken) { var qmax = options.Value; + if (message.Direction != MessageDirection.Incoming) + { + logger.LogDebug("Skipping push for non-incoming message {MessageId} in chat {ChatId}.", message.Id, chat.Id); + return; + } + if (!qmax.PushEnabled || chat.IsMuted) { return; @@ -70,7 +76,8 @@ public sealed class FirebasePushNotificationService( ["body"] = body, ["chatId"] = chat.Id.ToString(), ["messageId"] = message.Id.ToString(), - ["chatTitle"] = chat.Title + ["chatTitle"] = chat.Title, + ["direction"] = message.Direction.ToString() }; foreach (var device in devices) diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index ac79d62..18d6a3e 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -5,10 +5,12 @@ using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using QMax.Api.Configuration; using QMax.Api.Contracts; @@ -602,6 +604,60 @@ public sealed class ApiSmokeTests : IDisposable Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????")); } + [Fact] + public async Task FirebasePushSkipsOutgoingMessages() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"qmax-push-test-{Guid.NewGuid():N}.db"); + try + { + var dbOptions = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath};Pooling=False") + .Options; + await using (var db = new QMaxDbContext(dbOptions)) + { + await db.Database.EnsureCreatedAsync(); + var user = new User { DisplayName = "Push Test" }; + db.Users.Add(user); + db.PushDevices.Add(new PushDevice { User = user, FirebaseToken = "test-token", Platform = "android" }); + await db.SaveChangesAsync(); + + var service = new FirebasePushNotificationService( + db, + new ThrowingHttpClientFactory(), + Options.Create(new QMaxOptions + { + PushEnabled = true, + FirebaseProjectId = "qmax-test", + FirebaseServiceAccountJson = "{ this is not valid json" + }), + NullLogger.Instance); + var chat = new Chat { Title = "Self Chat" }; + var message = new Message + { + Chat = chat, + Direction = MessageDirection.Outgoing, + Text = "message from me", + DeliveryState = MessageDeliveryState.Sent + }; + + await service.SendNewMessageAsync(chat, message, CancellationToken.None); + + Assert.Equal(1, await db.PushDevices.CountAsync()); + } + } + finally + { + foreach (var suffix in new[] { "", "-shm", "-wal" }) + { + var path = databasePath + suffix; + if (File.Exists(path)) + { + File.Delete(path); + } + } + } + } + [Fact] public async Task AdminStatusRequiresPairingCode() { @@ -685,6 +741,14 @@ public sealed class ApiSmokeTests : IDisposable } } + private sealed class ThrowingHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) + { + throw new InvalidOperationException("Push for outgoing messages must not reach Firebase HTTP calls."); + } + } + private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient { public Task GetStatusAsync(CancellationToken cancellationToken) diff --git a/worker/src/server.js b/worker/src/server.js index 4847c08..99a5ea3 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -468,6 +468,7 @@ async function ensurePage() { viewport: { width: 1280, height: 900 }, args: ["--disable-dev-shm-usage", "--no-sandbox"] }); + await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: maxBaseUrl }).catch(() => {}); page = context.pages()[0] || await context.newPage(); attachNetworkCapture(page); if (!page.url() || page.url() === "about:blank") { @@ -2837,7 +2838,11 @@ async function sendTextAndConfirm(p, externalChatId, text) { return null; } - const initialMatches = await countOutgoingTextMatches(p, externalChatId, text); + const initialOutgoingMessages = await getOutgoingMessages(p, externalChatId); + const initialOutgoingIds = new Set(initialOutgoingMessages.map((message) => message.externalId).filter(Boolean)); + const initialMatches = initialOutgoingMessages + .filter((message) => messageTextMatches(message.text, wanted)) + .length; const attempts = [ { name: "send button", action: () => clickSendButton(p) }, { name: "Enter", action: async () => { await p.keyboard.press("Enter"); return true; } }, @@ -2860,7 +2865,13 @@ async function sendTextAndConfirm(p, externalChatId, text) { continue; } - const externalId = await findSentOutgoingExternalId(p, externalChatId, text, initialMatches, 10000); + const externalId = await findSentOutgoingExternalId( + p, + externalChatId, + text, + initialMatches, + initialOutgoingIds, + 10000); if (externalId) { return externalId; } @@ -2875,10 +2886,18 @@ async function sendTextAndConfirm(p, externalChatId, text) { } async function fillComposerText(p, text, clearFirst) { + if (clearFirst && prefersClipboardComposerInput(text) && await fillComposerViaClipboard(p, text, true)) { + return true; + } + if (clearFirst && await fillComposerViaLocator(p, text)) { return true; } + if (clearFirst && await fillComposerViaClipboard(p, text, true)) { + return true; + } + for (let attempt = 0; attempt < 2; attempt++) { if (!(await focusComposer(p))) { break; @@ -2931,6 +2950,131 @@ async function fillComposerText(p, text, clearFirst) { return false; } +function prefersClipboardComposerInput(text) { + return /[\p{Extended_Pictographic}\u200d\ufe0f]/u.test(String(text || "")); +} + +async function fillComposerViaClipboard(p, text, clearFirst) { + if (!(await focusComposer(p))) { + return false; + } + + if (clearFirst) { + await p.keyboard.press(process.platform === "darwin" ? "Meta+A" : "Control+A"); + await p.keyboard.press("Backspace"); + } + + const wroteClipboard = await p.evaluate(async (value) => { + try { + await navigator.clipboard.writeText(value); + return true; + } catch { + return false; + } + }, text).catch(() => false); + + if (wroteClipboard) { + await p.keyboard.press(process.platform === "darwin" ? "Meta+V" : "Control+V"); + } else if (!(await insertComposerTextViaDom(p, text, false))) { + return false; + } + + await p.waitForTimeout(300); + const currentText = await readComposerText(p); + return currentText !== null && messageTextMatches(currentText, text); +} + +async function insertComposerTextViaDom(p, text, clearFirst = false) { + return await p.evaluate(({ value, clear }) => { + const cleanText = (textValue) => String(textValue || "") + .replace(/\u00a0/g, " ") + .replace(/[\u200b\u200c\u200d\ufeff]/g, "") + .replace(/\s+/g, " ") + .trim(); + const isVisible = (element) => { + if (!(element instanceof HTMLElement)) return false; + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.visibility !== "hidden" && + style.display !== "none" && + rect.width > 0 && + rect.height > 0 && + rect.bottom >= 0 && + rect.right >= 0 && + rect.top <= window.innerHeight && + rect.left <= window.innerWidth; + }; + const isSearchLike = (element, rect) => { + const textValue = cleanText([ + element.getAttribute("aria-label"), + element.getAttribute("placeholder"), + element.getAttribute("title"), + element.getAttribute("data-testid"), + element.className, + element.id, + element.getAttribute("name") + ].join(" ")).toLowerCase(); + return /search|\u043f\u043e\u0438\u0441\u043a/.test(textValue) || + (rect.left < window.innerWidth * 0.42 && rect.top < window.innerHeight * 0.32); + }; + const selectors = [ + "[data-testid='composer'] [role='textbox']", + "[data-testid='composer'] [contenteditable='true']", + "[class*='composer' i] [role='textbox']", + "[class*='composer' i] [contenteditable='true']", + "[class*='composer' i] [class*='contenteditable' i]", + "[role='textbox'][class*='contenteditable' i]", + "[role='textbox']", + "[contenteditable='true']", + "textarea" + ]; + const candidates = selectors + .flatMap((selector) => Array.from(document.querySelectorAll(selector))) + .filter((element) => isVisible(element)) + .filter((element) => { + const rect = element.getBoundingClientRect(); + return !isSearchLike(element, rect) && + rect.left > window.innerWidth * 0.32 && + rect.top > window.innerHeight * 0.50; + }) + .sort((a, b) => { + const ar = a.getBoundingClientRect(); + const br = b.getBoundingClientRect(); + return br.bottom - ar.bottom || (br.width * br.height) - (ar.width * ar.height); + }); + const composer = candidates[0]; + if (!(composer instanceof HTMLElement)) { + return false; + } + + composer.focus({ preventScroll: true }); + if (clear) { + if ("value" in composer) { + composer.value = ""; + } else { + composer.textContent = ""; + } + composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward", data: null })); + } + + const beforeInput = new InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + inputType: "insertText", + data: value + }); + composer.dispatchEvent(beforeInput); + if ("value" in composer) { + composer.value = `${composer.value || ""}${value}`; + } else if (!document.execCommand("insertText", false, value)) { + composer.textContent = `${composer.textContent || ""}${value}`; + } + composer.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value })); + composer.dispatchEvent(new Event("change", { bubbles: true })); + return true; + }, { value: text, clear: clearFirst }).catch(() => false); +} + async function fillComposerViaLocator(p, text) { const selectors = [ "[data-testid='composer'] [role='textbox']", @@ -3145,32 +3289,53 @@ function messageTextMatches(value, wantedText) { )); } +async function getOutgoingMessages(p, externalChatId) { + const history = await extractOpenChatHistory(p, externalChatId).catch(() => null); + return (history?.messages || []).filter((message) => message.isOutgoing); +} + async function getOutgoingTextMatches(p, externalChatId, text) { const wanted = cleanComparableText(text, 1000); if (!wanted) { return []; } - const history = await extractOpenChatHistory(p, externalChatId).catch(() => null); - return (history?.messages || []).filter((message) => - message.isOutgoing && messageTextMatches(message.text, wanted)); + return (await getOutgoingMessages(p, externalChatId)) + .filter((message) => messageTextMatches(message.text, wanted)); } async function countOutgoingTextMatches(p, externalChatId, text) { return (await getOutgoingTextMatches(p, externalChatId, text)).length; } -async function findSentOutgoingExternalId(p, externalChatId, text, previousMatchCount = 0, timeoutMs = 15000) { +async function findSentOutgoingExternalId( + p, + externalChatId, + text, + previousMatchCount = 0, + initialOutgoingIds = new Set(), + timeoutMs = 15000) { + const knownIds = initialOutgoingIds instanceof Set + ? initialOutgoingIds + : new Set(Array.from(initialOutgoingIds || []).filter(Boolean)); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await p.waitForTimeout(500); - const matches = await getOutgoingTextMatches(p, externalChatId, text); + const outgoingMessages = await getOutgoingMessages(p, externalChatId); + const matches = outgoingMessages.filter((message) => messageTextMatches(message.text, text)); if (matches.length > previousMatchCount) { const latest = matches[matches.length - 1]; if (latest?.externalId) { return latest.externalId; } } + + const newOutgoing = outgoingMessages.filter((message) => + message.externalId && + !knownIds.has(message.externalId)); + if (newOutgoing.length > 0) { + return newOutgoing[newOutgoing.length - 1].externalId; + } } return null;