Fix emoji sending and self push notifications

This commit is contained in:
sevenhill
2026-07-04 21:47:06 +03:00
parent b3192d793d
commit 3e3d1538eb
4 changed files with 248 additions and 8 deletions
@@ -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"]
@@ -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)
+64
View File
@@ -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<QMaxDbContext>()
.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<FirebasePushNotificationService>.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<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
+172 -7
View File
@@ -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;