Fix emoji sending and self push notifications
This commit is contained in:
@@ -33,6 +33,10 @@ class QMaxFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.data["direction"].equals("Outgoing", ignoreCase = true)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val title = message.notification?.title
|
val title = message.notification?.title
|
||||||
?: message.data["title"]
|
?: message.data["title"]
|
||||||
?: message.data["chatTitle"]
|
?: message.data["chatTitle"]
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ public sealed class FirebasePushNotificationService(
|
|||||||
public async Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken)
|
public async Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var qmax = options.Value;
|
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)
|
if (!qmax.PushEnabled || chat.IsMuted)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -70,7 +76,8 @@ public sealed class FirebasePushNotificationService(
|
|||||||
["body"] = body,
|
["body"] = body,
|
||||||
["chatId"] = chat.Id.ToString(),
|
["chatId"] = chat.Id.ToString(),
|
||||||
["messageId"] = message.Id.ToString(),
|
["messageId"] = message.Id.ToString(),
|
||||||
["chatTitle"] = chat.Title
|
["chatTitle"] = chat.Title,
|
||||||
|
["direction"] = message.Direction.ToString()
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var device in devices)
|
foreach (var device in devices)
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using QMax.Api.Configuration;
|
using QMax.Api.Configuration;
|
||||||
using QMax.Api.Contracts;
|
using QMax.Api.Contracts;
|
||||||
@@ -602,6 +604,60 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????"));
|
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]
|
[Fact]
|
||||||
public async Task AdminStatusRequiresPairingCode()
|
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
|
private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient
|
||||||
{
|
{
|
||||||
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
|
|||||||
+172
-7
@@ -468,6 +468,7 @@ async function ensurePage() {
|
|||||||
viewport: { width: 1280, height: 900 },
|
viewport: { width: 1280, height: 900 },
|
||||||
args: ["--disable-dev-shm-usage", "--no-sandbox"]
|
args: ["--disable-dev-shm-usage", "--no-sandbox"]
|
||||||
});
|
});
|
||||||
|
await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: maxBaseUrl }).catch(() => {});
|
||||||
page = context.pages()[0] || await context.newPage();
|
page = context.pages()[0] || await context.newPage();
|
||||||
attachNetworkCapture(page);
|
attachNetworkCapture(page);
|
||||||
if (!page.url() || page.url() === "about:blank") {
|
if (!page.url() || page.url() === "about:blank") {
|
||||||
@@ -2837,7 +2838,11 @@ async function sendTextAndConfirm(p, externalChatId, text) {
|
|||||||
return null;
|
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 = [
|
const attempts = [
|
||||||
{ name: "send button", action: () => clickSendButton(p) },
|
{ name: "send button", action: () => clickSendButton(p) },
|
||||||
{ name: "Enter", action: async () => { await p.keyboard.press("Enter"); return true; } },
|
{ name: "Enter", action: async () => { await p.keyboard.press("Enter"); return true; } },
|
||||||
@@ -2860,7 +2865,13 @@ async function sendTextAndConfirm(p, externalChatId, text) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const externalId = await findSentOutgoingExternalId(p, externalChatId, text, initialMatches, 10000);
|
const externalId = await findSentOutgoingExternalId(
|
||||||
|
p,
|
||||||
|
externalChatId,
|
||||||
|
text,
|
||||||
|
initialMatches,
|
||||||
|
initialOutgoingIds,
|
||||||
|
10000);
|
||||||
if (externalId) {
|
if (externalId) {
|
||||||
return externalId;
|
return externalId;
|
||||||
}
|
}
|
||||||
@@ -2875,10 +2886,18 @@ async function sendTextAndConfirm(p, externalChatId, text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fillComposerText(p, text, clearFirst) {
|
async function fillComposerText(p, text, clearFirst) {
|
||||||
|
if (clearFirst && prefersClipboardComposerInput(text) && await fillComposerViaClipboard(p, text, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (clearFirst && await fillComposerViaLocator(p, text)) {
|
if (clearFirst && await fillComposerViaLocator(p, text)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clearFirst && await fillComposerViaClipboard(p, text, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
if (!(await focusComposer(p))) {
|
if (!(await focusComposer(p))) {
|
||||||
break;
|
break;
|
||||||
@@ -2931,6 +2950,131 @@ async function fillComposerText(p, text, clearFirst) {
|
|||||||
return false;
|
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) {
|
async function fillComposerViaLocator(p, text) {
|
||||||
const selectors = [
|
const selectors = [
|
||||||
"[data-testid='composer'] [role='textbox']",
|
"[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) {
|
async function getOutgoingTextMatches(p, externalChatId, text) {
|
||||||
const wanted = cleanComparableText(text, 1000);
|
const wanted = cleanComparableText(text, 1000);
|
||||||
if (!wanted) {
|
if (!wanted) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = await extractOpenChatHistory(p, externalChatId).catch(() => null);
|
return (await getOutgoingMessages(p, externalChatId))
|
||||||
return (history?.messages || []).filter((message) =>
|
.filter((message) => messageTextMatches(message.text, wanted));
|
||||||
message.isOutgoing && messageTextMatches(message.text, wanted));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function countOutgoingTextMatches(p, externalChatId, text) {
|
async function countOutgoingTextMatches(p, externalChatId, text) {
|
||||||
return (await getOutgoingTextMatches(p, externalChatId, text)).length;
|
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;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
await p.waitForTimeout(500);
|
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) {
|
if (matches.length > previousMatchCount) {
|
||||||
const latest = matches[matches.length - 1];
|
const latest = matches[matches.length - 1];
|
||||||
if (latest?.externalId) {
|
if (latest?.externalId) {
|
||||||
return 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;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user