Files
QMAX/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs
T
2026-07-05 18:30:46 +03:00

246 lines
11 KiB
C#

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<QMaxOptions> options,
ILogger<WorkerMaxBridgeClient> logger) : IMaxBridgeClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly QMaxOptions _options = options.Value;
public async Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
{
return await SendAsync<MaxBridgeStatus>(HttpMethod.Get, "/status", null, cancellationToken)
?? ErrorStatus("Worker returned an empty status.");
}
public async Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/start", new { phoneNumber }, cancellationToken)
?? ErrorStatus("Worker returned an empty login status.");
}
public async Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken)
{
return await SendAsync<MaxBridgeStatus>(HttpMethod.Post, "/login/code", new { code, waitMs = 60000 }, cancellationToken)
?? ErrorStatus("Worker returned an empty code status.");
}
public async Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
{
return await SendAsync<MaxBrowserSnapshot>(HttpMethod.Get, "/snapshot", null, cancellationToken)
?? new MaxBrowserSnapshot(null, "Worker unavailable", "", "", DateTimeOffset.UtcNow);
}
public async Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
{
return await SendAsync<IReadOnlyList<MaxChatUpdate>>(HttpMethod.Get, "/updates", null, cancellationToken)
?? Array.Empty<MaxChatUpdate>();
}
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId, chatUrl }, cancellationToken);
}
public async Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync<MaxChatPresence>(HttpMethod.Post, "/chat/presence", new { externalChatId, chatUrl }, cancellationToken);
}
public async Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return await SendAsync<MaxChatUrlResult>(HttpMethod.Post, "/chat/resolve-url", new { externalChatId }, cancellationToken)
?? new MaxChatUrlResult(false, null, "Worker returned an empty chat URL result.");
}
public async Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/chat/clear-history",
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty clear history result.");
}
public async Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/chat/delete",
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty delete chat result.");
}
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/text", new { externalChatId, chatUrl, text }, cancellationToken)
?? new MaxSendResult(false, null, "Worker returned an empty send result.");
}
public async Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken)
{
return await SendAsync<MaxSendResult>(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<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/message/edit",
new { externalChatId, externalMessageId, currentText, text },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty edit result.");
}
public async Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/message/delete",
new { externalChatId, externalMessageId, currentText },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty delete result.");
}
public async Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Post,
"/message/reaction",
new { externalChatId, externalMessageId, currentText, emoji },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty reaction result.");
}
public async Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return await SendAsync<MaxActionResult>(
HttpMethod.Delete,
"/message/reaction",
new { externalChatId, externalMessageId, currentText, emoji },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty reaction clear result.");
}
public async Task<MaxMediaDownload?> 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<T?> SendAsync<T>(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<T>(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<int> ReadAsync(Memory<byte> 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();
}
}
}