Isolate MAX chat commands from outbox sends
This commit is contained in:
@@ -16,9 +16,12 @@ public sealed class MaxOutboxService(
|
|||||||
IHubContext<QMaxHub> hubContext,
|
IHubContext<QMaxHub> hubContext,
|
||||||
ILogger<MaxOutboxService> logger)
|
ILogger<MaxOutboxService> logger)
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan InitialChatActionRetryDelay = TimeSpan.FromMinutes(10);
|
||||||
|
private static readonly TimeSpan MaxChatActionRetryDelay = TimeSpan.FromHours(6);
|
||||||
|
|
||||||
public async Task<int> ProcessOnceAsync(CancellationToken cancellationToken)
|
public async Task<int> ProcessOnceAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var processed = await ProcessPendingChatActionsAsync(cancellationToken);
|
var processed = 0;
|
||||||
|
|
||||||
var candidates = await db.Messages
|
var candidates = await db.Messages
|
||||||
.Include(message => message.Chat)
|
.Include(message => message.Chat)
|
||||||
@@ -47,18 +50,20 @@ public sealed class MaxOutboxService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return processed;
|
return processed + await ProcessPendingChatActionsAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int> ProcessPendingChatActionsAsync(CancellationToken cancellationToken)
|
private async Task<int> ProcessPendingChatActionsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
var pending = await db.Chats
|
var pending = await db.Chats
|
||||||
.Where(chat => chat.PendingMaxAction != null)
|
.Where(chat => chat.PendingMaxAction != null)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
pending = pending
|
pending = pending
|
||||||
|
.Where(chat => IsChatActionDue(chat, now))
|
||||||
.OrderBy(chat => chat.PendingMaxActionRequestedAt ?? chat.UpdatedAt)
|
.OrderBy(chat => chat.PendingMaxActionRequestedAt ?? chat.UpdatedAt)
|
||||||
.ThenBy(chat => chat.Id)
|
.ThenBy(chat => chat.Id)
|
||||||
.Take(10)
|
.Take(1)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var processed = 0;
|
var processed = 0;
|
||||||
@@ -74,6 +79,30 @@ public sealed class MaxOutboxService(
|
|||||||
return processed;
|
return processed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsChatActionDue(Chat chat, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (chat.PendingMaxActionLastAttemptAt is null)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return now - chat.PendingMaxActionLastAttemptAt.Value >= GetChatActionRetryDelay(chat.PendingMaxActionAttempts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan GetChatActionRetryDelay(int attempts)
|
||||||
|
{
|
||||||
|
if (attempts <= 1)
|
||||||
|
{
|
||||||
|
return InitialChatActionRetryDelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
var multiplier = Math.Pow(2, Math.Min(attempts - 1, 5));
|
||||||
|
var retryDelay = TimeSpan.FromTicks((long)(InitialChatActionRetryDelay.Ticks * multiplier));
|
||||||
|
return retryDelay <= MaxChatActionRetryDelay
|
||||||
|
? retryDelay
|
||||||
|
: MaxChatActionRetryDelay;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> ProcessChatActionAsync(Chat chat, CancellationToken cancellationToken)
|
private async Task<bool> ProcessChatActionAsync(Chat chat, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pendingAction = chat.PendingMaxAction;
|
var pendingAction = chat.PendingMaxAction;
|
||||||
@@ -110,6 +139,8 @@ public sealed class MaxOutboxService(
|
|||||||
{
|
{
|
||||||
chat.PendingMaxAction = null;
|
chat.PendingMaxAction = null;
|
||||||
chat.PendingMaxActionRequestedAt = null;
|
chat.PendingMaxActionRequestedAt = null;
|
||||||
|
chat.PendingMaxActionLastAttemptAt = null;
|
||||||
|
chat.PendingMaxActionAttempts = 0;
|
||||||
chat.PendingMaxActionError = null;
|
chat.PendingMaxActionError = null;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -753,6 +753,78 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OutboxSendsQueuedMessagesBeforePendingChatActions()
|
||||||
|
{
|
||||||
|
var bridge = new ResolvingDeleteMaxBridgeClient();
|
||||||
|
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<IHostedService>();
|
||||||
|
services.RemoveAll<IMaxBridgeClient>();
|
||||||
|
services.AddSingleton<IMaxBridgeClient>(bridge);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client);
|
||||||
|
|
||||||
|
var deleteChat = await CreateDirectChatAsync(client, "delete-after-send-chat", "Delete After Send");
|
||||||
|
var deleteResponse = await client.PostAsJsonAsync(
|
||||||
|
"/api/chats/delete",
|
||||||
|
new ChatBulkActionRequest([deleteChat.Id]),
|
||||||
|
JsonOptions);
|
||||||
|
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse);
|
||||||
|
|
||||||
|
var sendChat = await CreateDirectChatAsync(client, "send-before-delete-chat", "Send Before Delete");
|
||||||
|
var messageResponse = await client.PostAsJsonAsync(
|
||||||
|
$"/api/chats/{sendChat.Id}/messages",
|
||||||
|
new SendMessageRequest("send before chat action"),
|
||||||
|
JsonOptions);
|
||||||
|
await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
|
||||||
|
|
||||||
|
Assert.Equal(2, await ProcessOutboxAsync(factory));
|
||||||
|
Assert.Equal(["send:text", "delete"], bridge.OperationOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task FailedPendingChatActionWaitsBeforeImmediateRetry()
|
||||||
|
{
|
||||||
|
var bridge = new ResolvingDeleteMaxBridgeClient { AlwaysFailDelete = true };
|
||||||
|
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<IHostedService>();
|
||||||
|
services.RemoveAll<IMaxBridgeClient>();
|
||||||
|
services.AddSingleton<IMaxBridgeClient>(bridge);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client);
|
||||||
|
|
||||||
|
var chat = await CreateDirectChatAsync(client, "retry-delay-delete-chat", "Retry Delay Delete");
|
||||||
|
var deleteResponse = await client.PostAsJsonAsync(
|
||||||
|
"/api/chats/delete",
|
||||||
|
new ChatBulkActionRequest([chat.Id]),
|
||||||
|
JsonOptions);
|
||||||
|
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse);
|
||||||
|
|
||||||
|
Assert.True(await ProcessOutboxAsync(factory) >= 1);
|
||||||
|
Assert.Single(bridge.DeleteChatUrls);
|
||||||
|
|
||||||
|
await ProcessOutboxAsync(factory);
|
||||||
|
Assert.Single(bridge.DeleteChatUrls);
|
||||||
|
|
||||||
|
await using var scope = factory.Services.CreateAsyncScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||||
|
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
|
||||||
|
Assert.Equal(ChatPendingMaxAction.DeleteChat, stored.PendingMaxAction);
|
||||||
|
Assert.Equal(1, stored.PendingMaxActionAttempts);
|
||||||
|
Assert.NotNull(stored.PendingMaxActionLastAttemptAt);
|
||||||
|
Assert.Equal("Simulated MAX delete failure.", stored.PendingMaxActionError);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BulkClearHistoryQueuesMaxActionAndClearsLocalMessages()
|
public async Task BulkClearHistoryQueuesMaxActionAndClearsLocalMessages()
|
||||||
{
|
{
|
||||||
@@ -1662,6 +1734,10 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
public List<string?> DeleteChatUrls { get; } = [];
|
public List<string?> DeleteChatUrls { get; } = [];
|
||||||
public string? ClearChatUrl { get; private set; }
|
public string? ClearChatUrl { get; private set; }
|
||||||
public List<string?> ClearChatUrls { get; } = [];
|
public List<string?> ClearChatUrls { get; } = [];
|
||||||
|
public List<string> OperationOrder { get; } = [];
|
||||||
|
public List<string> SentTexts { get; } = [];
|
||||||
|
public bool AlwaysFailDelete { get; set; }
|
||||||
|
public bool AlwaysFailClear { get; set; }
|
||||||
public IReadOnlyList<MaxChatUpdate> Updates { get; set; } = [];
|
public IReadOnlyList<MaxChatUpdate> Updates { get; set; } = [];
|
||||||
|
|
||||||
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||||
@@ -1707,8 +1783,14 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
|
|
||||||
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
OperationOrder.Add("clear");
|
||||||
ClearChatUrls.Add(chatUrl);
|
ClearChatUrls.Add(chatUrl);
|
||||||
ClearChatUrl = chatUrl;
|
ClearChatUrl = chatUrl;
|
||||||
|
if (AlwaysFailClear)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxActionResult(false, "Simulated MAX clear failure."));
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(chatUrl, _staleUrlToReject, StringComparison.Ordinal))
|
if (string.Equals(chatUrl, _staleUrlToReject, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxActionResult(false, "MAX chat menu was not opened."));
|
return Task.FromResult(new MaxActionResult(false, "MAX chat menu was not opened."));
|
||||||
@@ -1721,8 +1803,14 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
|
|
||||||
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
OperationOrder.Add("delete");
|
||||||
DeleteChatUrls.Add(chatUrl);
|
DeleteChatUrls.Add(chatUrl);
|
||||||
DeleteChatUrl = chatUrl;
|
DeleteChatUrl = chatUrl;
|
||||||
|
if (AlwaysFailDelete)
|
||||||
|
{
|
||||||
|
return Task.FromResult(new MaxActionResult(false, "Simulated MAX delete failure."));
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(chatUrl, _staleUrlToReject, StringComparison.Ordinal))
|
if (string.Equals(chatUrl, _staleUrlToReject, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
return Task.FromResult(new MaxActionResult(false, "MAX chat menu was not opened."));
|
return Task.FromResult(new MaxActionResult(false, "MAX chat menu was not opened."));
|
||||||
@@ -1735,11 +1823,14 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
|
|
||||||
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
OperationOrder.Add("send:text");
|
||||||
|
SentTexts.Add(text);
|
||||||
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
|
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken)
|
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
OperationOrder.Add("send:attachment");
|
||||||
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
|
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+18
-8
@@ -347,7 +347,7 @@ app.post("/chat/resolve-url", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await withPageLock(async () => {
|
const result = await withPageLock(async () => {
|
||||||
const p = await ensurePage("incoming");
|
const p = await ensurePage("commands");
|
||||||
const currentStatus = await status(null, p);
|
const currentStatus = await status(null, p);
|
||||||
if (!currentStatus.isAuthorized) {
|
if (!currentStatus.isAuthorized) {
|
||||||
return { success: false, chatUrl: null, error: "MAX is not authorized." };
|
return { success: false, chatUrl: null, error: "MAX is not authorized." };
|
||||||
@@ -359,7 +359,7 @@ app.post("/chat/resolve-url", async (req, res) => {
|
|||||||
chatUrl: chatUrl || null,
|
chatUrl: chatUrl || null,
|
||||||
error: chatUrl ? null : "MAX chat URL was not found."
|
error: chatUrl ? null : "MAX chat URL was not found."
|
||||||
};
|
};
|
||||||
}, "incoming");
|
}, "commands");
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -376,9 +376,9 @@ app.post("/chat/clear-history", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await withPageLock(async () => {
|
const result = await withPageLock(async () => {
|
||||||
const p = await ensurePage("incoming");
|
const p = await ensurePage("commands");
|
||||||
return await clearChatHistoryInMax(p, externalChatId, chatUrl);
|
return await clearChatHistoryInMax(p, externalChatId, chatUrl);
|
||||||
}, "incoming");
|
}, "commands");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json({ success: false, error: String(error?.message || error) });
|
res.json({ success: false, error: String(error?.message || error) });
|
||||||
@@ -394,9 +394,9 @@ app.post("/chat/delete", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await withPageLock(async () => {
|
const result = await withPageLock(async () => {
|
||||||
const p = await ensurePage("incoming");
|
const p = await ensurePage("commands");
|
||||||
return await deleteChatInMax(p, externalChatId, chatUrl);
|
return await deleteChatInMax(p, externalChatId, chatUrl);
|
||||||
}, "incoming");
|
}, "commands");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json({ success: false, error: String(error?.message || error) });
|
res.json({ success: false, error: String(error?.message || error) });
|
||||||
@@ -416,7 +416,7 @@ app.post("/chat/menu-probe", async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await withPageLock(async () => {
|
const result = await withPageLock(async () => {
|
||||||
const p = await ensurePage("incoming");
|
const p = await ensurePage("commands");
|
||||||
const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, labels);
|
const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, labels);
|
||||||
const actionClicked = menuOpened && probeAction
|
const actionClicked = menuOpened && probeAction
|
||||||
? await clickActionByTextWithRetry(p, labels, 1500)
|
? await clickActionByTextWithRetry(p, labels, 1500)
|
||||||
@@ -434,7 +434,7 @@ app.post("/chat/menu-probe", async (req, res) => {
|
|||||||
actionClicked,
|
actionClicked,
|
||||||
visibleActions
|
visibleActions
|
||||||
};
|
};
|
||||||
}, "incoming");
|
}, "commands");
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json({ success: false, error: String(error?.message || error), chatUrl: null });
|
res.json({ success: false, error: String(error?.message || error), chatUrl: null });
|
||||||
@@ -759,6 +759,7 @@ async function status(overrideStatus = null, statusPage = null) {
|
|||||||
url,
|
url,
|
||||||
title,
|
title,
|
||||||
lastError,
|
lastError,
|
||||||
|
pageRoles: getOpenPageRoles(),
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -776,6 +777,7 @@ function errorStatus(error) {
|
|||||||
url: fallbackPage?.url?.() || null,
|
url: fallbackPage?.url?.() || null,
|
||||||
title: null,
|
title: null,
|
||||||
lastError,
|
lastError,
|
||||||
|
pageRoles: getOpenPageRoles(),
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -785,9 +787,17 @@ function firstKnownPage() {
|
|||||||
pagesByRole.get("default") ||
|
pagesByRole.get("default") ||
|
||||||
pagesByRole.get("incoming") ||
|
pagesByRole.get("incoming") ||
|
||||||
pagesByRole.get("outgoing") ||
|
pagesByRole.get("outgoing") ||
|
||||||
|
pagesByRole.get("commands") ||
|
||||||
null;
|
null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOpenPageRoles() {
|
||||||
|
return Array.from(pagesByRole.entries())
|
||||||
|
.filter(([, rolePage]) => rolePage && !rolePage.isClosed())
|
||||||
|
.map(([roleName]) => roleName)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
function isAllowedMediaUrl(value) {
|
function isAllowedMediaUrl(value) {
|
||||||
const raw = String(value || "").trim();
|
const raw = String(value || "").trim();
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
|
|||||||
Reference in New Issue
Block a user