Split MAX bot loops and make chats cache-first
This commit is contained in:
@@ -80,7 +80,7 @@ class QMaxApi {
|
||||
}
|
||||
|
||||
suspend fun messages(sessionServerUrl: String, token: String, chatId: String): List<MessageDto> {
|
||||
return get(sessionServerUrl, "/api/chats/$chatId/messages", token)
|
||||
return get(sessionServerUrl, "/api/chats/$chatId/messages?sync=false", token)
|
||||
}
|
||||
|
||||
suspend fun markChatRead(sessionServerUrl: String, token: String, chatId: String) {
|
||||
|
||||
@@ -23,6 +23,8 @@ import xyz.kusoft.qmax.core.model.QMaxSession
|
||||
import xyz.kusoft.qmax.core.network.QMaxHttpException
|
||||
import xyz.kusoft.qmax.core.realtime.QMaxRealtimeClient
|
||||
import java.io.File
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class QMaxUiState(
|
||||
val serverUrl: String = BuildConfig.QMAX_DEFAULT_SERVER_URL,
|
||||
@@ -328,7 +330,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
}
|
||||
realtime.joinChat(chat.id)
|
||||
}
|
||||
loadMessages(showLoading = true)
|
||||
loadMessages(showLoading = false)
|
||||
presencePollJob?.cancel()
|
||||
presencePollJob = viewModelScope.launch {
|
||||
while (state.value.selectedChat?.id == chat.id) {
|
||||
@@ -574,7 +576,45 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
val sentMessages = mutableListOf<MessageDto>()
|
||||
|
||||
if (attachmentUris.isEmpty()) {
|
||||
sentMessages += repository.sendMessage(session, chat.id, text, replyToMessageId)
|
||||
val pending = pendingTextMessage(chat.id, text, replyToMessageId)
|
||||
repository.cacheRealtimeMessage(session, pending)
|
||||
repository.clearDraft(chat.id)
|
||||
val current = state.value
|
||||
if (current.selectedChat?.id == chat.id) {
|
||||
state.value = current.copy(
|
||||
composerText = "",
|
||||
replyTarget = null,
|
||||
messages = upsertMessage(current.messages, pending)
|
||||
)
|
||||
}
|
||||
onSuccess()
|
||||
|
||||
try {
|
||||
val sent = repository.sendMessage(session, chat.id, text, replyToMessageId)
|
||||
repository.deleteCachedMessage(session, chat.id, pending.id)
|
||||
repository.cacheRealtimeMessage(session, sent)
|
||||
val afterSend = state.value
|
||||
if (afterSend.selectedChat?.id == chat.id) {
|
||||
state.value = afterSend.copy(
|
||||
messages = replaceMessage(afterSend.messages, pending.id, sent)
|
||||
)
|
||||
}
|
||||
loadChats()
|
||||
} catch (error: Throwable) {
|
||||
val failed = pending.copy(
|
||||
deliveryState = "Failed",
|
||||
error = error.message ?: "\u041e\u0448\u0438\u0431\u043a\u0430 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438"
|
||||
)
|
||||
repository.cacheRealtimeMessage(session, failed)
|
||||
val afterFailure = state.value
|
||||
if (afterFailure.selectedChat?.id == chat.id) {
|
||||
state.value = afterFailure.copy(
|
||||
messages = replaceMessage(afterFailure.messages, pending.id, failed)
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return@launchLoading
|
||||
} else {
|
||||
attachmentUris.forEachIndexed { index, uri ->
|
||||
val caption = text.takeIf { index == 0 && it.isNotBlank() }
|
||||
@@ -1101,6 +1141,23 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
|
||||
return (messages.filterNot { it.id == message.id } + message).sortedBy { it.sentAt }
|
||||
}
|
||||
|
||||
private fun replaceMessage(messages: List<MessageDto>, oldMessageId: String, message: MessageDto): List<MessageDto> {
|
||||
return (messages.filterNot { it.id == oldMessageId || it.id == message.id } + message).sortedBy { it.sentAt }
|
||||
}
|
||||
|
||||
private fun pendingTextMessage(chatId: String, text: String, replyToMessageId: String?): MessageDto {
|
||||
return MessageDto(
|
||||
id = "local-${UUID.randomUUID()}",
|
||||
chatId = chatId,
|
||||
senderName = "You",
|
||||
direction = "Outgoing",
|
||||
text = text,
|
||||
sentAt = OffsetDateTime.now().toString(),
|
||||
replyToMessageId = replyToMessageId,
|
||||
deliveryState = "Sending"
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeChats(
|
||||
chats: List<ChatDto>,
|
||||
selectedChatId: String? = state.value.selectedChat?.id
|
||||
|
||||
@@ -59,7 +59,12 @@ public sealed class ChatsController(
|
||||
}
|
||||
|
||||
[HttpGet("{chatId:guid}/messages")]
|
||||
public async Task<ActionResult<IReadOnlyList<MessageDto>>> GetMessages(Guid chatId, int take = 80, DateTimeOffset? before = null, CancellationToken cancellationToken = default)
|
||||
public async Task<ActionResult<IReadOnlyList<MessageDto>>> GetMessages(
|
||||
Guid chatId,
|
||||
int take = 80,
|
||||
DateTimeOffset? before = null,
|
||||
bool sync = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken);
|
||||
if (chat is null)
|
||||
@@ -67,7 +72,7 @@ public sealed class ChatsController(
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (before is null && !string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
if (sync && before is null && !string.IsNullOrWhiteSpace(chat.ExternalId))
|
||||
{
|
||||
await syncService.SyncChatHistoryAsync(chat.ExternalId, chat.WebUrl, cancellationToken);
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
@@ -21,8 +21,12 @@ public sealed class MaxOutboxService(
|
||||
|
||||
public async Task<int> ProcessOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var processed = 0;
|
||||
var processed = await ProcessPendingMessagesOnceAsync(cancellationToken);
|
||||
return processed + await ProcessPendingChatActionsOnceAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> ProcessPendingMessagesOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var candidates = await db.Messages
|
||||
.Include(message => message.Chat)
|
||||
.Include(message => message.Attachments)
|
||||
@@ -41,6 +45,7 @@ public sealed class MaxOutboxService(
|
||||
.Take(10)
|
||||
.ToArray();
|
||||
|
||||
var processed = 0;
|
||||
foreach (var message in pending)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -50,10 +55,10 @@ public sealed class MaxOutboxService(
|
||||
}
|
||||
}
|
||||
|
||||
return processed + await ProcessPendingChatActionsAsync(cancellationToken);
|
||||
return processed;
|
||||
}
|
||||
|
||||
private async Task<int> ProcessPendingChatActionsAsync(CancellationToken cancellationToken)
|
||||
public async Task<int> ProcessPendingChatActionsOnceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var pending = await db.Chats
|
||||
|
||||
@@ -13,6 +13,26 @@ public sealed class MaxOutboxWorker(
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var delay = TimeSpan.FromSeconds(Math.Max(1, _options.MaxOutboxPollIntervalSeconds));
|
||||
|
||||
await Task.WhenAll(
|
||||
RunOutboxLoopAsync(
|
||||
"message outbox",
|
||||
delay,
|
||||
(outbox, cancellationToken) => outbox.ProcessPendingMessagesOnceAsync(cancellationToken),
|
||||
stoppingToken),
|
||||
RunOutboxLoopAsync(
|
||||
"chat command outbox",
|
||||
delay,
|
||||
(outbox, cancellationToken) => outbox.ProcessPendingChatActionsOnceAsync(cancellationToken),
|
||||
stoppingToken));
|
||||
}
|
||||
|
||||
private async Task RunOutboxLoopAsync(
|
||||
string workerName,
|
||||
TimeSpan delay,
|
||||
Func<MaxOutboxService, CancellationToken, Task<int>> process,
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(delay);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
@@ -30,11 +50,11 @@ public sealed class MaxOutboxWorker(
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
|
||||
await outbox.ProcessOnceAsync(stoppingToken);
|
||||
await process(outbox, stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "QMAX outbox tick failed.");
|
||||
logger.LogWarning(ex, "QMAX {WorkerName} tick failed.", workerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,8 +602,9 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
{
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IHostedService>();
|
||||
services.RemoveAll<IMaxBridgeClient>();
|
||||
services.AddSingleton<IMaxBridgeClient, FailingActionMaxBridgeClient>();
|
||||
});
|
||||
@@ -659,8 +660,9 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
var bridge = new ResolvingDeleteMaxBridgeClient();
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IHostedService>();
|
||||
services.RemoveAll<IMaxBridgeClient>();
|
||||
services.AddSingleton<IMaxBridgeClient>(bridge);
|
||||
});
|
||||
@@ -703,6 +705,40 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetMessagesReturnsSqlCacheWithoutInlineMaxHistorySyncByDefault()
|
||||
{
|
||||
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 chat = await CreateDirectChatAsync(client, "cache-first-chat", "Cache First");
|
||||
var messageResponse = await client.PostAsJsonAsync(
|
||||
$"/api/chats/{chat.Id}/messages",
|
||||
new SendMessageRequest("cached sql message"),
|
||||
JsonOptions);
|
||||
await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
|
||||
|
||||
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
|
||||
|
||||
Assert.Contains(messages!, x => x.Text == "cached sql message");
|
||||
Assert.Equal(0, bridge.HistoryFetchCount);
|
||||
|
||||
messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages?sync=true", JsonOptions);
|
||||
|
||||
Assert.Contains(messages!, x => x.Text == "cached sql message");
|
||||
Assert.Equal(1, bridge.HistoryFetchCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
|
||||
{
|
||||
@@ -711,8 +747,9 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
var bridge = new ResolvingDeleteMaxBridgeClient(staleUrl);
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IHostedService>();
|
||||
services.RemoveAll<IMaxBridgeClient>();
|
||||
services.AddSingleton<IMaxBridgeClient>(bridge);
|
||||
});
|
||||
@@ -832,8 +869,9 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
var bridge = new ResolvingDeleteMaxBridgeClient();
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IHostedService>();
|
||||
services.RemoveAll<IMaxBridgeClient>();
|
||||
services.AddSingleton<IMaxBridgeClient>(bridge);
|
||||
});
|
||||
@@ -952,8 +990,9 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
{
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IHostedService>();
|
||||
services.RemoveAll<IMaxBridgeClient>();
|
||||
services.AddSingleton<IMaxBridgeClient, FailingSendMaxBridgeClient>();
|
||||
});
|
||||
@@ -1738,6 +1777,7 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
public List<string> SentTexts { get; } = [];
|
||||
public bool AlwaysFailDelete { get; set; }
|
||||
public bool AlwaysFailClear { get; set; }
|
||||
public int HistoryFetchCount { get; private set; }
|
||||
public IReadOnlyList<MaxChatUpdate> Updates { get; set; } = [];
|
||||
|
||||
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
@@ -1767,6 +1807,7 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
|
||||
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
HistoryFetchCount++;
|
||||
return Task.FromResult<MaxChatUpdate?>(null);
|
||||
}
|
||||
|
||||
|
||||
+14
-1
@@ -674,7 +674,10 @@ app.listen(port, () => {
|
||||
async function ensurePage(role = "default") {
|
||||
const normalizedRole = String(role || "default");
|
||||
const existing = pagesByRole.get(normalizedRole);
|
||||
if (existing && !existing.isClosed()) return existing;
|
||||
if (existing && !existing.isClosed()) {
|
||||
await waitForRolePageReady(existing, normalizedRole);
|
||||
return existing;
|
||||
}
|
||||
|
||||
await ensureContext();
|
||||
|
||||
@@ -695,9 +698,19 @@ async function ensurePage(role = "default") {
|
||||
pagesByRole.set(normalizedRole, rolePage);
|
||||
attachNetworkCapture(rolePage);
|
||||
await rolePage.goto(maxBaseUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||
await waitForRolePageReady(rolePage, normalizedRole);
|
||||
return rolePage;
|
||||
}
|
||||
|
||||
async function waitForRolePageReady(rolePage, role) {
|
||||
if (role === "default") {
|
||||
return;
|
||||
}
|
||||
|
||||
await rolePage.waitForLoadState("domcontentloaded", { timeout: 15000 }).catch(() => {});
|
||||
await waitForLoginStage(rolePage, "Authorized", 15000).catch(() => false);
|
||||
}
|
||||
|
||||
async function ensureContext() {
|
||||
if (context) return;
|
||||
if (!contextInitialization) {
|
||||
|
||||
Reference in New Issue
Block a user