Initial QMAX production app

This commit is contained in:
sevenhill
2026-07-03 22:39:48 +03:00
commit 9fcc659438
119 changed files with 17959 additions and 0 deletions
@@ -0,0 +1,19 @@
namespace QMax.Api.Infrastructure.Max;
public interface IMaxBridgeClient
{
Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken);
Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken);
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken);
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken);
Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken);
Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken);
Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken);
Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken);
Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken);
Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken);
}
@@ -0,0 +1,63 @@
namespace QMax.Api.Infrastructure.Max;
public sealed record MaxBridgeStatus(
string Mode,
bool IsAuthorized,
string? LoginStage,
string Status,
string? Url,
string? Title,
string? LastError,
DateTimeOffset UpdatedAt);
public sealed record MaxBrowserSnapshot(
string? Url,
string? Title,
string BodyText,
string ScreenshotPngBase64,
DateTimeOffset CapturedAt);
public sealed record MaxChatUpdate(
string ExternalId,
string Title,
string? AvatarUrl,
DateTimeOffset UpdatedAt,
IReadOnlyList<MaxMessageUpdate> Messages,
string? LastMessagePreview = null,
bool? LastMessageIsOutgoing = null,
DateTimeOffset? LastMessageAt = null,
string? LastMessageTimeText = null);
public sealed record MaxMessageUpdate(
string ExternalId,
string? SenderExternalId,
string? SenderName,
bool IsOutgoing,
string? Text,
DateTimeOffset SentAt,
IReadOnlyList<MaxAttachmentUpdate>? Attachments = null,
string? DeliveryState = null);
public sealed record MaxAttachmentUpdate(
string ExternalId,
string FileName,
string? ContentType,
long? FileSizeBytes,
string? RemoteUrl,
string? Kind,
int SortOrder,
string? TextContent = null);
public sealed record MaxSendResult(bool Success, string? ExternalMessageId, string? Error);
public sealed record MaxActionResult(bool Success, string? Error);
public sealed record MaxMediaDownload(
Stream Stream,
string ContentType,
long? ContentLength);
public sealed record MaxChatPresence(
bool IsTyping,
string? StatusText,
DateTimeOffset UpdatedAt);
@@ -0,0 +1,93 @@
namespace QMax.Api.Infrastructure.Max;
public sealed class MockMaxBridgeClient : IMaxBridgeClient
{
private readonly List<MaxChatUpdate> _updates =
[
new MaxChatUpdate(
"mock-qmax",
"QMAX smoke chat",
null,
DateTimeOffset.UtcNow,
[
new MaxMessageUpdate(
"mock-message-1",
"max",
"MAX",
false,
"Mock mode is active. Switch QMax:MaxMode to Playwright for real web.max.ru.",
DateTimeOffset.UtcNow)
])
];
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new MaxBridgeStatus("Mock", true, "Authorized", "MockReady", null, "Mock", null, DateTimeOffset.UtcNow));
}
public Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return GetStatusAsync(cancellationToken);
}
public Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken)
{
return GetStatusAsync(cancellationToken);
}
public Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new MaxBrowserSnapshot(null, "Mock", "Mock MAX bridge", "", DateTimeOffset.UtcNow));
}
public Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
{
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
return Task.FromResult(update);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(new MaxChatPresence(false, null, DateTimeOffset.UtcNow));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"mock-out-{Guid.NewGuid():N}", null));
}
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"mock-file-{Guid.NewGuid():N}", null));
}
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxMediaDownload?>(null);
}
}
@@ -0,0 +1,219 @@
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();
}
}
}