using System.Net.Http.Json; using System.Text.Json; using Microsoft.Extensions.Options; using QMax.Api.Configuration; namespace QMax.Api.Infrastructure.Max; public sealed class WorkerMaxBridgeClient( HttpClient httpClient, IOptions options, ILogger logger) : IMaxBridgeClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly QMaxOptions _options = options.Value; public async Task GetStatusAsync(CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Get, "/status", null, cancellationToken) ?? ErrorStatus("Worker returned an empty status."); } public async Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken) ?? ErrorStatus("Worker returned an empty login status."); } public async Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Post, "/login/code", new { code, waitMs = 60000 }, cancellationToken) ?? ErrorStatus("Worker returned an empty code status."); } public async Task GetSnapshotAsync(CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Get, "/snapshot", null, cancellationToken) ?? new MaxBrowserSnapshot(null, "Worker unavailable", "", "", DateTimeOffset.UtcNow); } public async Task> FetchUpdatesAsync(CancellationToken cancellationToken) { return await SendAsync>(HttpMethod.Get, "/updates", null, cancellationToken) ?? throw new InvalidOperationException("MAX worker returned an empty updates response."); } public async Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Post, "/chat/history", new { externalChatId, chatUrl }, cancellationToken); } public async Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Post, "/chat/presence", new { externalChatId, chatUrl }, cancellationToken); } public async Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Post, "/chat/resolve-url", new { externalChatId }, cancellationToken) ?? new MaxChatUrlResult(false, null, "Worker returned an empty chat URL result."); } public async Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return await SendAsync( HttpMethod.Post, "/chat/clear-history", new { externalChatId, chatUrl }, cancellationToken) ?? new MaxActionResult(false, "Worker returned an empty clear history result."); } public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult( false, "Chat deletion is disabled because MAX does not remove chats from the web client.")); } public async Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Post, "/send/text", new { externalChatId, chatUrl, text }, cancellationToken) ?? new MaxSendResult(false, null, "Worker returned an empty send result."); } public async Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken) { return await SendAsync(HttpMethod.Post, "/send/attachment", new { externalChatId, chatUrl, path = ToWorkerPath(path), caption }, cancellationToken) ?? new MaxSendResult(false, null, "Worker returned an empty attachment result."); } public async Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return await SendAsync( HttpMethod.Post, "/message/edit", new { externalChatId, externalMessageId, currentText, text }, cancellationToken) ?? new MaxActionResult(false, "Worker returned an empty edit result."); } public async Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) { return await SendAsync( HttpMethod.Post, "/message/delete", new { externalChatId, externalMessageId, currentText }, cancellationToken) ?? new MaxActionResult(false, "Worker returned an empty delete result."); } public async Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return await SendAsync( HttpMethod.Post, "/message/reaction", new { externalChatId, externalMessageId, currentText, emoji }, cancellationToken) ?? new MaxActionResult(false, "Worker returned an empty reaction result."); } public async Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return await SendAsync( HttpMethod.Delete, "/message/reaction", new { externalChatId, externalMessageId, currentText, emoji }, cancellationToken) ?? new MaxActionResult(false, "Worker returned an empty reaction clear result."); } public async Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(remoteUrl)) { return null; } try { httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/"); var path = $"media/fetch?url={Uri.EscapeDataString(remoteUrl)}"; var response = await httpClient.GetAsync(path, HttpCompletionOption.ResponseHeadersRead, cancellationToken); if (!response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync(cancellationToken); logger.LogWarning("MAX worker media fetch failed with {Status}: {Content}", response.StatusCode, content); response.Dispose(); return null; } var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream"; var contentLength = response.Content.Headers.ContentLength; var stream = await response.Content.ReadAsStreamAsync(cancellationToken); return new MaxMediaDownload(new ResponseDisposingStream(stream, response), contentType, contentLength); } catch (Exception ex) { logger.LogWarning(ex, "MAX worker media fetch request failed."); return null; } } private async Task SendAsync(HttpMethod method, string path, object? body, CancellationToken cancellationToken) { try { httpClient.BaseAddress ??= new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/"); using var request = new HttpRequestMessage(method, path.TrimStart('/')); if (body is not null) { request.Content = JsonContent.Create(body, options: JsonOptions); } using var response = await httpClient.SendAsync(request, cancellationToken); if (!response.IsSuccessStatusCode) { var content = await response.Content.ReadAsStringAsync(cancellationToken); logger.LogWarning("MAX worker {Path} failed with {Status}: {Content}", path, response.StatusCode, content); return default; } return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); } catch (Exception ex) { logger.LogWarning(ex, "MAX worker {Path} request failed.", path); return default; } } private static MaxBridgeStatus ErrorStatus(string error) { return new MaxBridgeStatus("Worker", false, "Unavailable", "WorkerUnavailable", null, null, error, DateTimeOffset.UtcNow); } private static string ToWorkerPath(string path) { var normalized = path.Replace('\\', '/'); return normalized.StartsWith("/data/", StringComparison.Ordinal) ? "/qmax-data/" + normalized["/data/".Length..] : normalized; } private sealed class ResponseDisposingStream(Stream inner, HttpResponseMessage response) : Stream { public override bool CanRead => inner.CanRead; public override bool CanSeek => inner.CanSeek; public override bool CanWrite => inner.CanWrite; public override long Length => inner.Length; public override long Position { get => inner.Position; set => inner.Position = value; } public override void Flush() => inner.Flush(); public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); public override void SetLength(long value) => inner.SetLength(value); public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => await inner.ReadAsync(buffer, cancellationToken); protected override void Dispose(bool disposing) { if (disposing) { inner.Dispose(); response.Dispose(); } base.Dispose(disposing); } public override async ValueTask DisposeAsync() { await inner.DisposeAsync(); response.Dispose(); await base.DisposeAsync(); } } }