220 lines
9.7 KiB
C#
220 lines
9.7 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, CancellationToken cancellationToken)
|
|
{
|
|
return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId }, cancellationToken);
|
|
}
|
|
|
|
public async Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
|
|
{
|
|
return await SendAsync<MaxChatPresence>(HttpMethod.Post, "/chat/presence", new { externalChatId }, cancellationToken);
|
|
}
|
|
|
|
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
|
|
{
|
|
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/text", new { externalChatId, text }, cancellationToken)
|
|
?? new MaxSendResult(false, null, "Worker returned an empty send result.");
|
|
}
|
|
|
|
public async Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken)
|
|
{
|
|
return await SendAsync<MaxSendResult>(HttpMethod.Post, "/send/attachment", new { externalChatId, 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();
|
|
}
|
|
}
|
|
}
|