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,26 @@
namespace QMax.Api.Configuration;
public sealed class QMaxOptions
{
public string PublicBaseUrl { get; set; } = "https://qmax.kusoft.xyz";
public string DatabasePath { get; set; } = "data/qmax.db";
public string StoragePath { get; set; } = "data/storage";
public string JwtIssuer { get; set; } = "QMAX";
public string JwtAudience { get; set; } = "QMAX.Android";
public string JwtSecret { get; set; } = "";
public string PairingCode { get; set; } = "";
public string MaxPhoneNumber { get; set; } = "";
public string MaxMode { get; set; } = "Worker";
public string MaxWorkerBaseUrl { get; set; } = "http://qmax-max-worker:3001";
public string MaxUserDataPath { get; set; } = "data/max-profile";
public bool MaxHeadless { get; set; } = true;
public int MaxPollIntervalSeconds { get; set; } = 6;
public long MaxUploadBytes { get; set; } = 25L * 1024 * 1024;
public string CorsAllowedOrigins { get; set; } = "";
public string ReleasesPath { get; set; } = "data/releases";
public bool PushEnabled { get; set; } = true;
public bool PushShowPreview { get; set; } = true;
public string FirebaseProjectId { get; set; } = "";
public string FirebaseServiceAccountPath { get; set; } = "";
public string FirebaseServiceAccountJson { get; set; } = "";
}
@@ -0,0 +1,15 @@
namespace QMax.Api.Contracts;
public sealed record AppUpdateManifestDto(
string Slug,
string Name,
string Version,
int AndroidVersionCode,
string Channel,
string Platform,
string PackageKind,
string DownloadPath,
long PackageSizeBytes,
string Sha256,
string Notes,
DateTimeOffset PublishedAt);
@@ -0,0 +1,7 @@
namespace QMax.Api.Contracts;
public sealed record DeviceLoginRequest(string PairingCode, string DeviceName);
public sealed record RefreshTokenRequest(string RefreshToken);
public sealed record AuthResponse(string AccessToken, string RefreshToken, DateTimeOffset ExpiresAt, UserDto User);
public sealed record UserDto(Guid Id, string DisplayName, string? PhoneNumber, string? AvatarPath);
public sealed record SessionDto(Guid Id, string DeviceName, DateTimeOffset CreatedAt, DateTimeOffset LastSeenAt, DateTimeOffset ExpiresAt, bool IsCurrent);
@@ -0,0 +1,53 @@
using QMax.Api.Data.Entities;
namespace QMax.Api.Contracts;
public sealed record ChatDto(
Guid Id,
string? ExternalId,
ChatKind Kind,
string Title,
string? AvatarUrl,
string? LastMessagePreview,
DateTimeOffset? LastMessageAt,
int UnreadCount,
bool IsPinned,
bool IsMuted,
bool IsArchived);
public sealed record MessageDto(
Guid Id,
Guid ChatId,
string? ExternalId,
string? SenderName,
MessageDirection Direction,
string? Text,
DateTimeOffset SentAt,
DateTimeOffset? EditedAt,
DateTimeOffset? DeletedAt,
Guid? ReplyToMessageId,
string? ForwardedFrom,
MessageDeliveryState DeliveryState,
string? Error,
IReadOnlyList<ReactionDto> Reactions,
IReadOnlyList<AttachmentDto> Attachments);
public sealed record ReactionDto(string Emoji, int Count, bool ReactedByMe);
public sealed record AttachmentDto(
Guid Id,
string FileName,
string ContentType,
long FileSizeBytes,
string DownloadPath,
AttachmentKind Kind,
int SortOrder);
public sealed record SendMessageRequest(string Text, Guid? ReplyToMessageId = null);
public sealed record EditMessageRequest(string Text);
public sealed record ForwardMessageRequest(Guid TargetChatId);
public sealed record SetReactionRequest(string Emoji);
public sealed record MessageDeletedDto(Guid ChatId, Guid MessageId);
public sealed record CreateDirectChatRequest(string ExternalChatId, string Title, string? AvatarUrl = null);
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
+20
View File
@@ -0,0 +1,20 @@
namespace QMax.Api.Contracts;
public sealed record BeginMaxLoginRequest(string? PhoneNumber);
public sealed record SubmitMaxCodeRequest(string Code);
public sealed record MaxBridgeStatusDto(
string Mode,
bool IsAuthorized,
string? LoginStage,
string Status,
string? Url,
string? Title,
string? LastError,
DateTimeOffset UpdatedAt);
public sealed record MaxBrowserSnapshotDto(
string? Url,
string? Title,
string BodyText,
string ScreenshotPngBase64,
DateTimeOffset CapturedAt);
@@ -0,0 +1,481 @@
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
namespace QMax.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Route("admin")]
public sealed class AdminController(
QMaxDbContext db,
IOptions<QMaxOptions> options,
IAttachmentStorageService storage,
IMaxBridgeClient maxBridge) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[HttpGet]
public async Task<IActionResult> Index([FromQuery] string? pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode))
{
return Content(AdminShell("<h1>QMAX Admin</h1><p>Open this page with <code>?pairingCode=...</code>.</p>"), "text/html", Encoding.UTF8);
}
var safePairingCode = pairingCode ?? "";
var status = await BuildStatusAsync(cancellationToken);
var encodedPairingCode = Uri.EscapeDataString(safePairingCode);
var latestChats = (await db.Chats.AsNoTracking().ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.LastMessageAt ?? x.UpdatedAt)
.Take(20)
.Select(x => new
{
x.Id,
x.Title,
x.Kind,
x.UnreadCount,
LastMessageAt = x.LastMessageAt ?? x.UpdatedAt,
x.LastMessagePreview
})
.ToArray();
var latestAttachments = (await db.MessageAttachments
.AsNoTracking()
.Include(x => x.Message)
.ThenInclude(x => x!.Chat)
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.CreatedAt)
.Take(20)
.Select(x => new
{
x.Id,
ChatTitle = x.Message!.Chat!.Title,
x.OriginalFileName,
x.Kind,
x.ContentType,
x.FileSizeBytes,
x.CreatedAt
})
.ToArray();
var latestUsers = (await db.Users
.AsNoTracking()
.Include(x => x.Sessions)
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.UpdatedAt)
.Take(20)
.Select(x => new
{
x.Id,
x.DisplayName,
x.PhoneNumber,
x.CreatedAt,
x.UpdatedAt,
Sessions = x.Sessions.Count,
ActiveSessions = x.Sessions.Count(session => session.RevokedAt == null && session.ExpiresAt > DateTimeOffset.UtcNow)
})
.ToArray();
var rows = string.Join("", latestChats.Select(chat => $$"""
<tr>
<td>{{Html(chat.Title)}}</td>
<td>{{Html(chat.Kind.ToString())}}</td>
<td>{{chat.UnreadCount}}</td>
<td>{{Html(chat.LastMessageAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
<td>{{Html(chat.LastMessagePreview)}}</td>
</tr>
"""));
var attachmentRows = string.Join("", latestAttachments.Select(attachment => $$"""
<tr>
<td><a href="/admin/api/attachments/{{attachment.Id}}/content?pairingCode={{encodedPairingCode}}">{{Html(attachment.OriginalFileName)}}</a></td>
<td>{{Html(attachment.ChatTitle)}}</td>
<td>{{Html(attachment.Kind.ToString())}}</td>
<td>{{Html(attachment.ContentType)}}</td>
<td>{{FormatBytes(attachment.FileSizeBytes)}}</td>
<td>{{Html(attachment.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
</tr>
"""));
var userRows = string.Join("", latestUsers.Select(user => $$"""
<tr>
<td>{{Html(user.DisplayName)}}</td>
<td>{{Html(user.PhoneNumber)}}</td>
<td>{{user.ActiveSessions}} / {{user.Sessions}}</td>
<td>{{Html(user.CreatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
<td>{{Html(user.UpdatedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm"))}}</td>
<td><code>{{user.Id}}</code></td>
</tr>
"""));
var html = $$"""
<h1>QMAX Admin</h1>
<nav>
<a href="/admin?pairingCode={{encodedPairingCode}}">Dashboard</a>
<a href="/admin/max?pairingCode={{encodedPairingCode}}">MAX Browser</a>
<a href="/admin/api/status?pairingCode={{encodedPairingCode}}">Status JSON</a>
<a href="/admin/api/chats?pairingCode={{encodedPairingCode}}">Chats JSON</a>
<a href="/admin/api/messages?pairingCode={{encodedPairingCode}}">Messages JSON</a>
<a href="/admin/api/attachments?pairingCode={{encodedPairingCode}}">Attachments JSON</a>
<a href="/admin/api/users?pairingCode={{encodedPairingCode}}">Users JSON</a>
</nav>
<section class="metrics">
<div><b>{{status.Users}}</b><span>Users</span></div>
<div><b>{{status.Chats}}</b><span>Chats</span></div>
<div><b>{{status.Messages}}</b><span>Messages</span></div>
<div><b>{{status.Attachments}}</b><span>Attachments</span></div>
<div><b>{{FormatBytes(status.AttachmentBytes)}}</b><span>Media storage</span></div>
<div><b>{{Html(status.MaxStatus)}}</b><span>MAX</span></div>
<div><b>{{status.PushDevices}}</b><span>Push devices</span></div>
</section>
<section>
<h2>Latest chats</h2>
<table>
<thead><tr><th>Title</th><th>Kind</th><th>Unread</th><th>Updated</th><th>Preview</th></tr></thead>
<tbody>{{rows}}</tbody>
</table>
</section>
<section>
<h2>Users</h2>
<table>
<thead><tr><th>Name</th><th>Phone</th><th>Sessions</th><th>Created</th><th>Updated</th><th>Id</th></tr></thead>
<tbody>{{userRows}}</tbody>
</table>
</section>
<section>
<h2>Latest attachments</h2>
<table>
<thead><tr><th>File</th><th>Chat</th><th>Kind</th><th>Content type</th><th>Size</th><th>Created</th></tr></thead>
<tbody>{{attachmentRows}}</tbody>
</table>
</section>
""";
return Content(AdminShell(html), "text/html", Encoding.UTF8);
}
[HttpGet("api/status")]
public async Task<IActionResult> Status([FromQuery] string? pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
return Ok(await BuildStatusAsync(cancellationToken));
}
[HttpGet("api/chats")]
public async Task<IActionResult> Chats([FromQuery] string? pairingCode, [FromQuery] string? q, [FromQuery] int take = 100, CancellationToken cancellationToken = default)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var query = db.Chats.AsNoTracking();
if (!string.IsNullOrWhiteSpace(q))
{
query = query.Where(x => x.Title.Contains(q) || (x.LastMessagePreview != null && x.LastMessagePreview.Contains(q)));
}
var chats = (await query
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.LastMessageAt ?? x.UpdatedAt)
.Take(Math.Clamp(take, 1, 500))
.Select(x => new
{
x.Id,
x.ExternalId,
x.Kind,
x.Title,
x.UnreadCount,
x.IsPinned,
x.IsMuted,
x.IsArchived,
x.LastMessageAt,
x.LastMessagePreview,
x.UpdatedAt
})
.ToArray();
return Ok(chats);
}
[HttpGet("api/messages")]
public async Task<IActionResult> Messages([FromQuery] string? pairingCode, [FromQuery] Guid? chatId, [FromQuery] string? q, [FromQuery] int take = 100, CancellationToken cancellationToken = default)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var query = db.Messages.AsNoTracking().Include(x => x.Chat).Include(x => x.Attachments).Where(x => x.DeletedAt == null);
if (chatId is not null)
{
query = query.Where(x => x.ChatId == chatId);
}
if (!string.IsNullOrWhiteSpace(q))
{
query = query.Where(x => x.Text != null && x.Text.Contains(q));
}
var messages = (await query
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.SentAt)
.Take(Math.Clamp(take, 1, 500))
.Select(x => new
{
x.Id,
x.ChatId,
ChatTitle = x.Chat!.Title,
x.Direction,
x.SenderName,
x.Text,
x.SentAt,
x.DeliveryState,
x.Error,
Attachments = x.Attachments.Count
})
.ToArray();
return Ok(messages);
}
[HttpGet("api/users")]
public async Task<IActionResult> Users([FromQuery] string? pairingCode, [FromQuery] int take = 100, CancellationToken cancellationToken = default)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var users = (await db.Users
.AsNoTracking()
.Include(x => x.Sessions)
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.UpdatedAt)
.Take(Math.Clamp(take, 1, 500))
.Select(x => new
{
x.Id,
x.DisplayName,
x.PhoneNumber,
x.AvatarPath,
x.CreatedAt,
x.UpdatedAt,
Sessions = x.Sessions
.OrderByDescending(session => session.LastSeenAt)
.Select(session => new
{
session.Id,
session.DeviceName,
session.CreatedAt,
session.LastSeenAt,
session.ExpiresAt,
IsActive = session.RevokedAt == null && session.ExpiresAt > DateTimeOffset.UtcNow,
session.RevokedAt
})
.ToArray()
})
.ToArray();
return Ok(users);
}
[HttpGet("api/attachments")]
public async Task<IActionResult> Attachments([FromQuery] string? pairingCode, [FromQuery] Guid? chatId, [FromQuery] int take = 100, CancellationToken cancellationToken = default)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var query = db.MessageAttachments.AsNoTracking().Include(x => x.Message).ThenInclude(x => x!.Chat).AsQueryable();
if (chatId is not null)
{
query = query.Where(x => x.Message!.ChatId == chatId);
}
var attachments = (await query
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.CreatedAt)
.Take(Math.Clamp(take, 1, 500))
.Select(x => new
{
x.Id,
x.MessageId,
ChatId = x.Message!.ChatId,
ChatTitle = x.Message.Chat!.Title,
FileName = x.OriginalFileName,
x.ContentType,
x.FileSizeBytes,
x.Kind,
x.RemoteUrl,
x.CreatedAt,
ContentPath = $"/admin/api/attachments/{x.Id}/content"
})
.ToArray();
return Ok(attachments);
}
[HttpGet("api/attachments/{id:guid}/content")]
public async Task<IActionResult> AttachmentContent(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var attachment = await db.MessageAttachments.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (attachment is null)
{
return NotFound();
}
var path = storage.GetPath(attachment.StorageFileName);
return System.IO.File.Exists(path)
? PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true)
: NotFound();
}
[HttpDelete("api/attachments/{id:guid}")]
public async Task<IActionResult> DeleteAttachment(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var attachment = await db.MessageAttachments.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (attachment is null)
{
return NotFound();
}
var path = storage.GetPath(attachment.StorageFileName);
db.MessageAttachments.Remove(attachment);
await db.SaveChangesAsync(cancellationToken);
if (System.IO.File.Exists(path))
{
System.IO.File.Delete(path);
}
return NoContent();
}
[HttpDelete("api/users/{id:guid}")]
public async Task<IActionResult> DeleteUser(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var user = await db.Users.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (user is null)
{
return NotFound();
}
await db.UserSessions.Where(x => x.UserId == id).ExecuteDeleteAsync(cancellationToken);
await db.PushDevices.Where(x => x.UserId == id).ExecuteDeleteAsync(cancellationToken);
db.Users.Remove(user);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("api/chats/{id:guid}")]
public async Task<IActionResult> DeleteChat(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var chat = await db.Chats
.Include(x => x.Messages)
.ThenInclude(x => x.Attachments)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (chat is null)
{
return NotFound();
}
var files = chat.Messages
.SelectMany(x => x.Attachments)
.Select(x => storage.GetPath(x.StorageFileName))
.Where(System.IO.File.Exists)
.ToArray();
db.Chats.Remove(chat);
await db.SaveChangesAsync(cancellationToken);
foreach (var file in files)
{
System.IO.File.Delete(file);
}
return NoContent();
}
private async Task<AdminStatusDto> BuildStatusAsync(CancellationToken cancellationToken)
{
var maxStatus = await maxBridge.GetStatusAsync(cancellationToken);
return new AdminStatusDto(
"QMAX Bridge",
DateTimeOffset.UtcNow,
await db.Users.CountAsync(cancellationToken),
await db.Chats.CountAsync(cancellationToken),
await db.Messages.CountAsync(cancellationToken),
await db.MessageAttachments.CountAsync(cancellationToken),
await db.MessageAttachments.Select(x => (long?)x.FileSizeBytes).SumAsync(cancellationToken) ?? 0,
await db.PushDevices.CountAsync(cancellationToken),
maxStatus.IsAuthorized,
maxStatus.Status,
maxStatus.LastError);
}
private bool IsPairingCodeValid(string? pairingCode)
{
return !string.IsNullOrWhiteSpace(_options.PairingCode) &&
string.Equals(pairingCode, _options.PairingCode, StringComparison.Ordinal);
}
private static string AdminShell(string body)
{
return $$"""
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QMAX Admin</title>
<style>
body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f3f7fa; color: #17212b; }
main { max-width: 1320px; margin: 0 auto; padding: 18px; }
h1 { margin: 0 0 14px; font-size: 28px; }
h2 { margin-top: 26px; font-size: 18px; }
nav { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
nav a { color: #2578c4; text-decoration: none; font-weight: 600; }
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 16px 0; }
.metrics div { background: white; border: 1px solid #dde7ee; border-radius: 8px; padding: 12px; }
.metrics b { display: block; font-size: 24px; }
.metrics span { color: #6c7883; font-size: 13px; }
table { width: 100%; border-collapse: collapse; background: white; border: 1px solid #dde7ee; border-radius: 8px; overflow: hidden; }
th, td { padding: 9px 10px; border-bottom: 1px solid #edf2f5; text-align: left; vertical-align: top; }
th { color: #6c7883; font-size: 12px; text-transform: uppercase; letter-spacing: .02em; }
tr:last-child td { border-bottom: 0; }
code { background: #e7eef3; padding: 2px 5px; border-radius: 5px; }
</style>
</head>
<body><main>{{body}}</main></body>
</html>
""";
}
private static string Html(string? value)
{
return System.Net.WebUtility.HtmlEncode(value ?? "");
}
private static string FormatBytes(long bytes)
{
if (bytes < 1024) return $"{bytes} B";
var kb = bytes / 1024.0;
if (kb < 1024) return $"{kb:0.0} KB";
var mb = kb / 1024.0;
if (mb < 1024) return $"{mb:0.0} MB";
return $"{mb / 1024.0:0.0} GB";
}
private sealed record AdminStatusDto(
string Name,
DateTimeOffset ServerTime,
int Users,
int Chats,
int Messages,
int Attachments,
long AttachmentBytes,
int PushDevices,
bool MaxAuthorized,
string MaxStatus,
string? MaxLastError);
}
@@ -0,0 +1,236 @@
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Infrastructure.Max;
namespace QMax.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Route("admin/max")]
public sealed class AdminMaxController(
IHttpClientFactory httpClientFactory,
IOptions<QMaxOptions> options) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[HttpGet]
public async Task<IActionResult> Index([FromQuery] string pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode))
{
return Content(AdminShell("<h1>QMAX MAX Admin</h1><p>Open this page with <code>?pairingCode=...</code>.</p>"), "text/html", Encoding.UTF8);
}
var snapshot = await WorkerGetAsync<MaxBrowserSnapshot>("snapshot", cancellationToken);
var status = await WorkerGetAsync<MaxBridgeStatus>("status", cancellationToken);
var screenshot = string.IsNullOrWhiteSpace(snapshot?.ScreenshotPngBase64)
? "<p>No screenshot yet.</p>"
: $"<img src=\"data:image/png;base64,{snapshot.ScreenshotPngBase64}\" />";
var encodedPairingCode = Uri.EscapeDataString(pairingCode);
var jsAuthorized = status?.IsAuthorized == true ? "true" : "false";
var jsStage = JsonSerializer.Serialize(status?.LoginStage ?? "");
var html = $$"""
<h1>QMAX MAX Admin</h1>
<p><b>Status:</b> {{Html(status?.Status)}} | <b>Stage:</b> {{Html(status?.LoginStage)}} | <b>Authorized:</b> {{status?.IsAuthorized}} | <b>URL:</b> {{Html(snapshot?.Url)}}</p>
<form method="post" action="/admin/max/login/start">
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
<button type="submit">Start phone login</button>
</form>
<form method="post" action="/admin/max/login/code">
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
<input name="code" placeholder="MAX code" autocomplete="one-time-code" inputmode="numeric" />
<button type="submit">Submit code</button>
</form>
<form method="post" action="/admin/max/click">
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
<input name="x" placeholder="x" />
<input name="y" placeholder="y" />
<button type="submit">Click</button>
</form>
<form method="post" action="/admin/max/type">
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
<input name="text" placeholder="text" />
<button type="submit">Type</button>
</form>
<form method="post" action="/admin/max/press">
<input type="hidden" name="pairingCode" value="{{Html(pairingCode)}}" />
<input name="key" placeholder="Enter" />
<button type="submit">Press key</button>
</form>
<p class="links">
<a href="/admin/max?pairingCode={{encodedPairingCode}}">Refresh</a>
<a href="/admin/max/inspect/dom?pairingCode={{encodedPairingCode}}">DOM</a>
<a href="/admin/max/inspect/storage?pairingCode={{encodedPairingCode}}">Storage</a>
<a href="/admin/max/inspect/indexeddb-sample?pairingCode={{encodedPairingCode}}">IDB samples</a>
<a href="/admin/max/inspect/network?pairingCode={{encodedPairingCode}}">Network</a>
<a href="/admin/max/updates?pairingCode={{encodedPairingCode}}">Updates</a>
</p>
<p class="hint">Click directly on the screenshot to control the remote MAX browser. QMAX will forward the click and refresh the image.</p>
<div class="screen">{{screenshot}}</div>
<script>
const qmaxPairingCode = "{{Html(pairingCode)}}";
const qmaxIsAuthorized = {{jsAuthorized}};
const qmaxStage = {{jsStage}};
const qmaxScreen = document.querySelector(".screen img");
if (qmaxScreen) {
qmaxScreen.addEventListener("click", async (event) => {
const rect = qmaxScreen.getBoundingClientRect();
const x = Math.round((event.clientX - rect.left) * qmaxScreen.naturalWidth / rect.width);
const y = Math.round((event.clientY - rect.top) * qmaxScreen.naturalHeight / rect.height);
const body = new URLSearchParams({ pairingCode: qmaxPairingCode, x: String(x), y: String(y) });
await fetch("/admin/max/click", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body
});
window.setTimeout(() => window.location.reload(), 1200);
});
}
const qmaxIsEditing = () => {
const active = document.activeElement;
return active && ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName);
};
if (!qmaxIsAuthorized) {
const intervalMs = qmaxStage === "CodeEntry" ? 3000 : 5000;
window.setInterval(() => {
if (!document.hidden && !qmaxIsEditing()) {
window.location.reload();
}
}, intervalMs);
}
</script>
""";
return Content(AdminShell(html), "text/html", Encoding.UTF8);
}
[HttpGet("inspect/{kind}")]
public async Task<IActionResult> Inspect([FromRoute] string kind, [FromQuery] string pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var workerPath = kind.ToLowerInvariant() switch
{
"dom" => "inspect/dom",
"storage" => "inspect/storage",
"indexeddb-sample" => "inspect/indexeddb/sample",
"network" => "inspect/network",
_ => null
};
if (workerPath is null) return NotFound();
return Content(await WorkerGetRawAsync(workerPath, cancellationToken), "application/json", Encoding.UTF8);
}
[HttpGet("updates")]
public async Task<IActionResult> Updates([FromQuery] string pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
return Content(await WorkerGetRawAsync("updates", cancellationToken), "application/json", Encoding.UTF8);
}
[HttpPost("login/start")]
public async Task<IActionResult> StartLogin([FromForm] string pairingCode, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
await WorkerPostAsync("login/start", new { phoneNumber = _options.MaxPhoneNumber }, cancellationToken);
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
}
[HttpPost("login/code")]
public async Task<IActionResult> Code([FromForm] string pairingCode, [FromForm] string code, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
await WorkerPostAsync("login/code", new { code, waitMs = 60000 }, cancellationToken);
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
}
[HttpPost("click")]
public async Task<IActionResult> Click([FromForm] string pairingCode, [FromForm] int x, [FromForm] int y, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
await WorkerPostAsync("browser/click", new { x, y }, cancellationToken);
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
}
[HttpPost("type")]
public async Task<IActionResult> Type([FromForm] string pairingCode, [FromForm] string text, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
await WorkerPostAsync("browser/type", new { text }, cancellationToken);
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
}
[HttpPost("press")]
public async Task<IActionResult> Press([FromForm] string pairingCode, [FromForm] string key, CancellationToken cancellationToken)
{
if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
await WorkerPostAsync("browser/press", new { key = string.IsNullOrWhiteSpace(key) ? "Enter" : key }, cancellationToken);
return Redirect($"/admin/max?pairingCode={Uri.EscapeDataString(pairingCode)}");
}
private async Task<T?> WorkerGetAsync<T>(string path, CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient();
client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
return await client.GetFromJsonAsync<T>(path, cancellationToken);
}
private async Task<string> WorkerGetRawAsync(string path, CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient();
client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
return await client.GetStringAsync(path, cancellationToken);
}
private async Task WorkerPostAsync(string path, object body, CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient();
client.BaseAddress = new Uri(_options.MaxWorkerBaseUrl.TrimEnd('/') + "/");
using var response = await client.PostAsJsonAsync(path, body, cancellationToken);
response.EnsureSuccessStatusCode();
}
private bool IsPairingCodeValid(string pairingCode)
{
return !string.IsNullOrWhiteSpace(_options.PairingCode) &&
string.Equals(pairingCode, _options.PairingCode, StringComparison.Ordinal);
}
private static string AdminShell(string body)
{
return $$"""
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>QMAX MAX Admin</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; background: #f3f7fa; color: #17212b; }
main { max-width: 1320px; margin: 0 auto; padding: 16px; }
form { display: inline-flex; gap: 8px; margin: 4px; align-items: center; }
input { padding: 8px 10px; border: 1px solid #ccd6dd; border-radius: 6px; }
button { padding: 8px 12px; border: 0; border-radius: 6px; color: white; background: #3390ec; }
.links { display: flex; flex-wrap: wrap; gap: 12px; }
.hint { color: #5f7080; }
.screen { margin-top: 16px; overflow: auto; background: #111; }
img { max-width: 100%; display: block; cursor: crosshair; }
</style>
</head>
<body><main>{{body}}</main></body>
</html>
""";
}
private static string Html(string? value)
{
return System.Net.WebUtility.HtmlEncode(value ?? "");
}
}
@@ -0,0 +1,79 @@
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Contracts;
namespace QMax.Api.Controllers;
[ApiController]
[Route("api/app-updates")]
public sealed class AppUpdatesController(IOptions<QMaxOptions> options) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[AllowAnonymous]
[HttpGet("android/latest")]
public async Task<ActionResult<AppUpdateManifestDto>> Latest(CancellationToken cancellationToken)
{
var manifestPath = Path.Combine(_options.ReleasesPath, "android", "latest.json");
if (System.IO.File.Exists(manifestPath))
{
return PhysicalFile(manifestPath, "application/json");
}
var androidReleases = new DirectoryInfo(Path.Combine(_options.ReleasesPath, "android"));
if (!androidReleases.Exists)
{
return NotFound();
}
var apk = androidReleases
.GetFiles("*.apk")
.OrderByDescending(x => x.LastWriteTimeUtc)
.FirstOrDefault();
if (apk is null)
{
return NotFound();
}
var manifest = new AppUpdateManifestDto(
"qmax",
"QMAX",
"0.1.0",
1,
"stable",
"android",
"apk",
$"/api/app-updates/android/download/{Uri.EscapeDataString(apk.Name)}",
apk.Length,
await Sha256Async(apk.FullName, cancellationToken),
"QMAX local release",
apk.LastWriteTimeUtc);
return manifest;
}
[AllowAnonymous]
[HttpGet("android/download/{fileName}")]
public IActionResult Download(string fileName)
{
var safeName = Path.GetFileName(fileName);
var path = Path.Combine(_options.ReleasesPath, "android", safeName);
if (!System.IO.File.Exists(path) || !safeName.EndsWith(".apk", StringComparison.OrdinalIgnoreCase))
{
return NotFound();
}
return PhysicalFile(path, "application/vnd.android.package-archive", safeName, enableRangeProcessing: true);
}
private static async Task<string> Sha256Async(string path, CancellationToken cancellationToken)
{
await using var stream = System.IO.File.OpenRead(path);
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
@@ -0,0 +1,130 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Controllers;
[ApiController]
[Route("api/auth")]
public sealed class AuthController(
QMaxDbContext db,
ITokenService tokenService,
IOptions<QMaxOptions> options) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[AllowAnonymous]
[HttpPost("device/login")]
public async Task<ActionResult<AuthResponse>> Login(DeviceLoginRequest request, CancellationToken cancellationToken)
{
var expected = _options.PairingCode;
if (string.IsNullOrWhiteSpace(expected))
{
return Problem("QMax:PairingCode is not configured on the server.", statusCode: StatusCodes.Status503ServiceUnavailable);
}
if (!FixedTimeEquals(expected, request.PairingCode))
{
return Unauthorized();
}
var user = await db.Users.FirstOrDefaultAsync(cancellationToken);
if (user is null)
{
user = new User
{
DisplayName = "QMAX Owner",
PhoneNumber = string.IsNullOrWhiteSpace(_options.MaxPhoneNumber) ? null : _options.MaxPhoneNumber
};
db.Users.Add(user);
}
var refreshToken = tokenService.CreateRefreshToken();
var session = new UserSession
{
User = user,
DeviceName = string.IsNullOrWhiteSpace(request.DeviceName) ? "Android" : request.DeviceName.Trim(),
RefreshTokenHash = tokenService.HashRefreshToken(refreshToken)
};
db.UserSessions.Add(session);
await db.SaveChangesAsync(cancellationToken);
return CreateAuthResponse(user, session, refreshToken);
}
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult<AuthResponse>> Refresh(RefreshTokenRequest request, CancellationToken cancellationToken)
{
var hash = tokenService.HashRefreshToken(request.RefreshToken);
var session = await db.UserSessions
.Include(x => x.User)
.FirstOrDefaultAsync(x => x.RefreshTokenHash == hash, cancellationToken);
if (session?.User is null || session.RevokedAt is not null || session.ExpiresAt <= DateTimeOffset.UtcNow)
{
return Unauthorized();
}
session.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return CreateAuthResponse(session.User, session, request.RefreshToken);
}
[Authorize]
[HttpPost("logout")]
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
var sessionId = User.GetSessionId();
if (sessionId is not null)
{
var session = await db.UserSessions.FindAsync([sessionId.Value], cancellationToken);
if (session is not null)
{
session.RevokedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
}
return NoContent();
}
[Authorize]
[HttpGet("sessions")]
public async Task<ActionResult<IReadOnlyList<SessionDto>>> Sessions(CancellationToken cancellationToken)
{
var userId = User.GetUserId();
var currentSessionId = User.GetSessionId();
var sessions = (await db.UserSessions
.Where(x => x.UserId == userId && x.RevokedAt == null)
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.LastSeenAt)
.Select(x => new SessionDto(x.Id, x.DeviceName, x.CreatedAt, x.LastSeenAt, x.ExpiresAt, x.Id == currentSessionId))
.ToArray();
return sessions;
}
private AuthResponse CreateAuthResponse(User user, UserSession session, string refreshToken)
{
var expiresAt = DateTimeOffset.UtcNow.AddHours(8);
var accessToken = tokenService.CreateAccessToken(user, session, expiresAt);
var dto = new UserDto(user.Id, user.DisplayName, user.PhoneNumber, user.AvatarPath);
return new AuthResponse(accessToken, refreshToken, expiresAt, dto);
}
private static bool FixedTimeEquals(string expected, string actual)
{
var expectedBytes = System.Text.Encoding.UTF8.GetBytes(expected);
var actualBytes = System.Text.Encoding.UTF8.GetBytes(actual);
return expectedBytes.Length == actualBytes.Length &&
System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(expectedBytes, actualBytes);
}
}
@@ -0,0 +1,823 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Services;
namespace QMax.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/chats")]
public sealed class ChatsController(
QMaxDbContext db,
ChatProjectionService projection,
IMaxBridgeClient maxBridge,
IAttachmentStorageService storage,
IHubContext<QMaxHub> hubContext,
MaxBridgeSyncService syncService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ChatDto>>> GetChats(CancellationToken cancellationToken)
{
var chats = (await db.Chats.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.IsPinned)
.ThenByDescending(x => x.UnreadCount > 0)
.ThenByDescending(x => x.LastMessageAt ?? x.UpdatedAt)
.Take(200)
.ToArray();
return chats.Select(projection.ToDto).ToArray();
}
[HttpPost("direct")]
public async Task<ActionResult<ChatDto>> CreateDirect(CreateDirectChatRequest request, CancellationToken cancellationToken)
{
var chat = await db.Chats.FirstOrDefaultAsync(x => x.ExternalId == request.ExternalChatId, cancellationToken);
if (chat is null)
{
chat = new Chat
{
ExternalId = request.ExternalChatId,
Title = request.Title,
AvatarUrl = request.AvatarUrl,
Kind = ChatKind.MaxDialog
};
db.Chats.Add(chat);
await db.SaveChangesAsync(cancellationToken);
}
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return projection.ToDto(chat);
}
[HttpGet("{chatId:guid}/messages")]
public async Task<ActionResult<IReadOnlyList<MessageDto>>> GetMessages(Guid chatId, int take = 80, DateTimeOffset? before = null, CancellationToken cancellationToken = default)
{
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
if (chat is null)
{
return NotFound();
}
if (before is null && !string.IsNullOrWhiteSpace(chat.ExternalId))
{
await syncService.SyncChatHistoryAsync(chat.ExternalId, cancellationToken);
db.ChangeTracker.Clear();
chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
if (chat is null)
{
return NotFound();
}
}
if (before is null && chat.UnreadCount > 0)
{
chat.UnreadCount = 0;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
}
var query = db.Messages
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.Where(x => x.ChatId == chatId && x.DeletedAt == null);
if (before is not null)
{
query = query.Where(x => x.SentAt < before);
}
var messages = (await query.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.SentAt)
.Take(Math.Clamp(take, 1, 200))
.ToArray();
return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray();
}
[HttpPost("{chatId:guid}/read")]
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
{
var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
if (chat is null)
{
return NotFound();
}
if (chat.UnreadCount > 0)
{
chat.UnreadCount = 0;
chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
}
return NoContent();
}
[HttpGet("{chatId:guid}/search")]
public async Task<ActionResult<IReadOnlyList<MessageDto>>> SearchMessages(
Guid chatId,
[FromQuery(Name = "q")] string? query,
int take = 50,
CancellationToken cancellationToken = default)
{
var term = query?.Trim();
if (string.IsNullOrWhiteSpace(term))
{
return BadRequest("Search query is required.");
}
var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId, cancellationToken);
if (!chatExists)
{
return NotFound();
}
var messages = await db.Messages
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.Where(x => x.ChatId == chatId && x.DeletedAt == null)
.ToArrayAsync(cancellationToken);
var results = messages
.Where(x => MatchesSearch(x, term))
.OrderByDescending(x => x.SentAt)
.Take(Math.Clamp(take, 1, 200))
.OrderBy(x => x.SentAt)
.Select(projection.ToDto)
.ToArray();
return results;
}
[HttpGet("{chatId:guid}/avatar")]
public async Task<IActionResult> DownloadAvatar(Guid chatId, CancellationToken cancellationToken)
{
var chat = await db.Chats
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
if (chat is null || string.IsNullOrWhiteSpace(chat.AvatarUrl))
{
return NotFound();
}
var remote = await maxBridge.DownloadMediaAsync(chat.AvatarUrl, cancellationToken);
if (remote is null)
{
return NotFound();
}
return File(remote.Stream, remote.ContentType);
}
[HttpGet("{chatId:guid}/presence")]
public async Task<ActionResult<ChatPresenceDto>> GetPresence(Guid chatId, CancellationToken cancellationToken)
{
var chat = await db.Chats
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken);
if (chat is null)
{
return NotFound();
}
if (string.IsNullOrWhiteSpace(chat.ExternalId))
{
return new ChatPresenceDto(false, null, DateTimeOffset.UtcNow);
}
var presence = await maxBridge.FetchChatPresenceAsync(chat.ExternalId, cancellationToken);
return new ChatPresenceDto(
presence?.IsTyping == true,
presence?.StatusText,
presence?.UpdatedAt ?? DateTimeOffset.UtcNow);
}
[HttpPost("{chatId:guid}/messages")]
public async Task<ActionResult<MessageDto>> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken)
{
var chat = await db.Chats.FindAsync([chatId], cancellationToken);
if (chat is null)
{
return NotFound();
}
var text = (request.Text ?? "").Trim();
if (string.IsNullOrWhiteSpace(text))
{
return BadRequest("Message text is required.");
}
if (string.IsNullOrWhiteSpace(chat.ExternalId))
{
return BadRequest("Chat is not linked to MAX.");
}
var result = await maxBridge.SendTextAsync(chat.ExternalId, text, cancellationToken);
if (!result.Success)
{
return MaxSendFailure(result, "text message");
}
var message = new Message
{
ChatId = chat.Id,
ExternalId = result.ExternalMessageId,
Direction = MessageDirection.Outgoing,
Text = text,
ReplyToMessageId = request.ReplyToMessageId,
SentAt = DateTimeOffset.UtcNow,
DeliveryState = MessageDeliveryState.Sent,
SenderName = "You"
};
db.Messages.Add(message);
chat.LastMessagePreview = text;
chat.LastMessageAt = message.SentAt;
chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
[HttpPatch("{chatId:guid}/messages/{messageId:guid}")]
public async Task<ActionResult<MessageDto>> EditMessage(
Guid chatId,
Guid messageId,
EditMessageRequest request,
CancellationToken cancellationToken)
{
var text = request.Text.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return BadRequest("Message text is required.");
}
var message = await db.Messages
.Include(x => x.Chat)
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
if (message is null)
{
return NotFound();
}
if (message.Direction != MessageDirection.Outgoing)
{
return Forbid();
}
var maxFailure = await TryApplyMaxActionAsync(
message,
(externalChatId, externalMessageId) => maxBridge.EditMessageAsync(externalChatId, externalMessageId, message.Text, text, cancellationToken),
"edit",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
message.Text = text;
message.EditedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
if (message.Chat is not null)
{
await RecalculateChatPreviewAsync(message.Chat, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
}
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
[HttpDelete("{chatId:guid}/messages/{messageId:guid}")]
public async Task<IActionResult> DeleteMessage(Guid chatId, Guid messageId, CancellationToken cancellationToken)
{
var message = await db.Messages
.Include(x => x.Chat)
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
if (message is null)
{
return NotFound();
}
var maxFailure = await TryApplyMaxActionAsync(
message,
(externalChatId, externalMessageId) => maxBridge.DeleteMessageAsync(externalChatId, externalMessageId, message.Text, cancellationToken),
"delete",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
var deletedAt = DateTimeOffset.UtcNow;
message.DeletedAt = deletedAt;
message.EditedAt = message.EditedAt ?? deletedAt;
await db.SaveChangesAsync(cancellationToken);
if (message.Chat is not null)
{
await RecalculateChatPreviewAsync(message.Chat, cancellationToken);
await db.SaveChangesAsync(cancellationToken);
}
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageDeleted", new MessageDeletedDto(chatId, messageId), cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return NoContent();
}
[HttpPut("{chatId:guid}/messages/{messageId:guid}/reaction")]
public async Task<ActionResult<MessageDto>> SetReaction(
Guid chatId,
Guid messageId,
SetReactionRequest request,
CancellationToken cancellationToken)
{
var emoji = NormalizeReaction(request.Emoji);
if (emoji is null)
{
return BadRequest("Reaction is required.");
}
var message = await db.Messages
.Include(x => x.Chat)
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
if (message is null)
{
return NotFound();
}
var maxFailure = await TryApplyMaxActionAsync(
message,
(externalChatId, externalMessageId) => maxBridge.SetReactionAsync(externalChatId, externalMessageId, message.Text, emoji, cancellationToken),
"reaction",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self");
if (reaction is null)
{
reaction = new MessageReaction
{
MessageId = message.Id,
Emoji = emoji,
ActorKey = "self",
ActorName = "You"
};
db.MessageReactions.Add(reaction);
}
else
{
reaction.Emoji = emoji;
reaction.ActorName = "You";
reaction.CreatedAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message;
var dto = projection.ToDto(updated);
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
return dto;
}
[HttpDelete("{chatId:guid}/messages/{messageId:guid}/reaction")]
public async Task<ActionResult<MessageDto>> ClearReaction(Guid chatId, Guid messageId, CancellationToken cancellationToken)
{
var message = await db.Messages
.Include(x => x.Chat)
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
if (message is null)
{
return NotFound();
}
var reaction = message.Reactions.FirstOrDefault(x => x.ActorKey == "self");
if (reaction is not null)
{
var maxFailure = await TryApplyMaxActionAsync(
message,
(externalChatId, externalMessageId) => maxBridge.ClearReactionAsync(externalChatId, externalMessageId, message.Text, reaction.Emoji, cancellationToken),
"reaction clear",
cancellationToken);
if (maxFailure is not null)
{
return maxFailure;
}
message.Reactions.Remove(reaction);
db.MessageReactions.Remove(reaction);
await db.SaveChangesAsync(cancellationToken);
}
var updated = await LoadMessageForProjectionAsync(chatId, messageId, cancellationToken) ?? message;
var dto = projection.ToDto(updated);
await hubContext.Clients.Group($"chat:{chatId}").SendAsync("MessageUpdated", dto, cancellationToken);
return dto;
}
[HttpPost("{chatId:guid}/messages/{messageId:guid}/forward")]
public async Task<ActionResult<MessageDto>> ForwardMessage(
Guid chatId,
Guid messageId,
ForwardMessageRequest request,
CancellationToken cancellationToken)
{
var source = await db.Messages
.Include(x => x.Chat)
.Include(x => x.Attachments)
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
if (source is null)
{
return NotFound();
}
var target = await db.Chats.FindAsync([request.TargetChatId], cancellationToken);
if (target is null)
{
return NotFound();
}
if (string.IsNullOrWhiteSpace(target.ExternalId))
{
return BadRequest("Target chat is not linked to MAX.");
}
if (string.IsNullOrWhiteSpace(source.Text) && source.Attachments.Count == 0)
{
return BadRequest("Cannot forward an empty message.");
}
var message = new Message
{
ChatId = target.Id,
Direction = MessageDirection.Outgoing,
Text = source.Text,
ForwardedFrom = ResolveForwardedFrom(source),
SentAt = DateTimeOffset.UtcNow,
DeliveryState = MessageDeliveryState.Sending,
SenderName = "You"
};
var sortOrder = 0;
foreach (var attachment in source.Attachments.OrderBy(x => x.SortOrder))
{
StoredAttachment stored;
try
{
stored = await CopyForwardAttachmentAsync(attachment, cancellationToken);
}
catch (InvalidOperationException error)
{
return Problem(error.Message, statusCode: StatusCodes.Status409Conflict);
}
message.Attachments.Add(new MessageAttachment
{
OriginalFileName = stored.OriginalFileName,
StorageFileName = stored.StorageFileName,
ContentType = stored.ContentType,
FileSizeBytes = stored.FileSizeBytes,
Sha256 = stored.Sha256,
Kind = stored.Kind,
SortOrder = sortOrder++
});
}
var result = await SendForwardedToMaxAsync(target.ExternalId, message, cancellationToken);
if (!result.Success)
{
return MaxSendFailure(result, "forward");
}
message.ExternalId = result.ExternalMessageId;
message.DeliveryState = MessageDeliveryState.Sent;
db.Messages.Add(message);
target.LastMessagePreview = ForwardPreview(message);
target.LastMessageAt = message.SentAt;
target.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{target.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
[HttpPost("{chatId:guid}/attachments")]
[RequestSizeLimit(30L * 1024 * 1024)]
public async Task<ActionResult<MessageDto>> UploadAttachment(
Guid chatId,
[FromForm] IFormFile file,
[FromForm] string? caption,
[FromForm] Guid? replyToMessageId,
CancellationToken cancellationToken)
{
var chat = await db.Chats.FindAsync([chatId], cancellationToken);
if (chat is null)
{
return NotFound();
}
if (string.IsNullOrWhiteSpace(chat.ExternalId))
{
return BadRequest("Chat is not linked to MAX.");
}
var stored = await storage.SaveAsync(file, cancellationToken);
var result = await maxBridge.SendAttachmentAsync(chat.ExternalId, storage.GetPath(stored.StorageFileName), caption ?? "", cancellationToken);
if (!result.Success)
{
return MaxSendFailure(result, "attachment");
}
var message = new Message
{
ChatId = chat.Id,
ExternalId = result.ExternalMessageId,
Direction = MessageDirection.Outgoing,
Text = caption,
ReplyToMessageId = replyToMessageId,
SentAt = DateTimeOffset.UtcNow,
DeliveryState = MessageDeliveryState.Sent,
SenderName = "You",
Attachments =
[
new MessageAttachment
{
OriginalFileName = stored.OriginalFileName,
StorageFileName = stored.StorageFileName,
ContentType = stored.ContentType,
FileSizeBytes = stored.FileSizeBytes,
Sha256 = stored.Sha256,
Kind = stored.Kind
}
]
};
db.Messages.Add(message);
chat.LastMessagePreview = !string.IsNullOrWhiteSpace(caption)
? caption
: stored.Kind switch
{
AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442",
_ => stored.OriginalFileName
};
chat.LastMessageAt = message.SentAt;
chat.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
return dto;
}
[HttpGet("{chatId:guid}/attachments/{attachmentId:guid}/content")]
public async Task<IActionResult> DownloadAttachment(Guid chatId, Guid attachmentId, CancellationToken cancellationToken)
{
var attachment = await db.MessageAttachments
.Include(x => x.Message)
.FirstOrDefaultAsync(x => x.Id == attachmentId && x.Message!.ChatId == chatId, cancellationToken);
if (attachment is null)
{
return NotFound();
}
var path = storage.GetPath(attachment.StorageFileName);
if (!System.IO.File.Exists(path))
{
if (string.IsNullOrWhiteSpace(attachment.RemoteUrl))
{
return NotFound();
}
var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken);
if (remote is null)
{
return NotFound();
}
await using (remote.Stream)
{
var stored = await storage.SaveRemoteAsync(
attachment.OriginalFileName,
remote.ContentType,
remote.Stream,
remote.ContentLength ?? attachment.FileSizeBytes,
attachment.Kind,
cancellationToken);
attachment.OriginalFileName = stored.OriginalFileName;
attachment.StorageFileName = stored.StorageFileName;
attachment.ContentType = stored.ContentType;
attachment.FileSizeBytes = stored.FileSizeBytes;
attachment.Sha256 = stored.Sha256;
attachment.Kind = stored.Kind;
await db.SaveChangesAsync(cancellationToken);
path = storage.GetPath(attachment.StorageFileName);
}
}
return PhysicalFile(path, attachment.ContentType, attachment.OriginalFileName, enableRangeProcessing: true);
}
private ObjectResult MaxSendFailure(MaxSendResult result, string itemName)
{
return Problem(
result.Error ?? $"MAX did not accept the {itemName}.",
statusCode: StatusCodes.Status502BadGateway);
}
private async Task<ActionResult?> TryApplyMaxActionAsync(
Message message,
Func<string, string, Task<MaxActionResult>> action,
string actionName,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var externalChatId = message.Chat?.ExternalId;
var externalMessageId = message.ExternalId;
if (string.IsNullOrWhiteSpace(externalChatId) || string.IsNullOrWhiteSpace(externalMessageId))
{
return null;
}
var result = await action(externalChatId, externalMessageId);
if (result.Success)
{
return null;
}
return Problem(
result.Error ?? $"MAX {actionName} action failed.",
statusCode: StatusCodes.Status409Conflict);
}
private static string? NormalizeReaction(string? value)
{
var emoji = value?.Trim();
return string.IsNullOrWhiteSpace(emoji) || emoji.Length > 16 ? null : emoji;
}
private async Task<Message?> LoadMessageForProjectionAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken)
{
return await db.Messages
.AsNoTracking()
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.FirstOrDefaultAsync(x => x.ChatId == chatId && x.Id == messageId && x.DeletedAt == null, cancellationToken);
}
private async Task RecalculateChatPreviewAsync(Chat chat, CancellationToken cancellationToken)
{
var lastMessage = (await db.Messages
.Include(x => x.Attachments)
.Where(x => x.ChatId == chat.Id && x.DeletedAt == null)
.ToArrayAsync(cancellationToken))
.OrderByDescending(x => x.SentAt)
.FirstOrDefault();
chat.LastMessageAt = lastMessage?.SentAt;
chat.LastMessagePreview = lastMessage is null ? null : ForwardPreview(lastMessage);
chat.UpdatedAt = DateTimeOffset.UtcNow;
}
private async Task<StoredAttachment> CopyForwardAttachmentAsync(MessageAttachment attachment, CancellationToken cancellationToken)
{
var path = storage.GetPath(attachment.StorageFileName);
if (System.IO.File.Exists(path))
{
await using var stream = System.IO.File.OpenRead(path);
return await storage.SaveRemoteAsync(
attachment.OriginalFileName,
attachment.ContentType,
stream,
attachment.FileSizeBytes,
attachment.Kind,
cancellationToken);
}
if (!string.IsNullOrWhiteSpace(attachment.RemoteUrl))
{
var remote = await maxBridge.DownloadMediaAsync(attachment.RemoteUrl, cancellationToken);
if (remote is not null)
{
await using (remote.Stream)
{
return await storage.SaveRemoteAsync(
attachment.OriginalFileName,
remote.ContentType,
remote.Stream,
remote.ContentLength ?? attachment.FileSizeBytes,
attachment.Kind,
cancellationToken);
}
}
}
throw new InvalidOperationException($"Cannot forward attachment {attachment.Id}: content is unavailable.");
}
private async Task<MaxSendResult> SendForwardedToMaxAsync(string externalChatId, Message message, CancellationToken cancellationToken)
{
var attachments = message.Attachments.OrderBy(x => x.SortOrder).ToArray();
if (attachments.Length == 0)
{
return string.IsNullOrWhiteSpace(message.Text)
? new MaxSendResult(false, null, "Forwarded message is empty.")
: await maxBridge.SendTextAsync(externalChatId, message.Text, cancellationToken);
}
MaxSendResult? lastResult = null;
var errors = new List<string>();
foreach (var attachment in attachments)
{
var caption = attachment.SortOrder == 0 ? message.Text ?? "" : "";
var result = await maxBridge.SendAttachmentAsync(externalChatId, storage.GetPath(attachment.StorageFileName), caption, cancellationToken);
lastResult = result;
if (!result.Success && !string.IsNullOrWhiteSpace(result.Error))
{
errors.Add(result.Error);
}
}
return errors.Count == 0
? new MaxSendResult(true, lastResult?.ExternalMessageId, null)
: new MaxSendResult(false, lastResult?.ExternalMessageId, string.Join("; ", errors.Distinct()));
}
private static string? ResolveForwardedFrom(Message source)
{
return source.SenderName?.Trim().Length > 0
? source.SenderName.Trim()
: source.Chat?.Title;
}
private static string ForwardPreview(Message message)
{
if (!string.IsNullOrWhiteSpace(message.Text))
{
return message.Text;
}
var first = message.Attachments.OrderBy(x => x.SortOrder).FirstOrDefault();
return first?.Kind switch
{
AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442",
_ => first?.OriginalFileName ?? "\u041f\u0435\u0440\u0435\u0441\u043b\u0430\u043d\u043d\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435"
};
}
private static bool MatchesSearch(Message message, string term)
{
return Contains(message.Text, term) ||
Contains(message.SenderName, term) ||
Contains(message.ForwardedFrom, term) ||
message.Attachments.Any(x =>
Contains(x.OriginalFileName, term) ||
Contains(x.ContentType, term)) ||
message.Reactions.Any(x => Contains(x.Emoji, term));
}
private static bool Contains(string? value, string term)
{
return value?.Contains(term, StringComparison.OrdinalIgnoreCase) == true;
}
}
@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Services;
namespace QMax.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/max")]
public sealed class MaxController(
IMaxBridgeClient maxBridge,
MaxBridgeSyncService syncService,
QMaxDbContext db,
IOptions<QMaxOptions> options) : ControllerBase
{
private readonly QMaxOptions _options = options.Value;
[HttpGet("status")]
public async Task<ActionResult<MaxBridgeStatusDto>> Status(CancellationToken cancellationToken)
{
var status = await maxBridge.GetStatusAsync(cancellationToken);
await SaveStateAsync(status, cancellationToken);
return ToDto(status);
}
[HttpPost("login/start")]
public async Task<ActionResult<MaxBridgeStatusDto>> BeginLogin(BeginMaxLoginRequest request, CancellationToken cancellationToken)
{
var phone = string.IsNullOrWhiteSpace(request.PhoneNumber) ? _options.MaxPhoneNumber : request.PhoneNumber;
if (string.IsNullOrWhiteSpace(phone))
{
return BadRequest("Phone number is required.");
}
var status = await maxBridge.BeginPhoneLoginAsync(phone, cancellationToken);
await SaveStateAsync(status, cancellationToken);
return ToDto(status);
}
[HttpPost("login/code")]
public async Task<ActionResult<MaxBridgeStatusDto>> SubmitCode(SubmitMaxCodeRequest request, CancellationToken cancellationToken)
{
var status = await maxBridge.SubmitLoginCodeAsync(request.Code, cancellationToken);
await SaveStateAsync(status, cancellationToken);
return ToDto(status);
}
[HttpGet("browser/snapshot")]
public async Task<ActionResult<MaxBrowserSnapshotDto>> Snapshot(CancellationToken cancellationToken)
{
var snapshot = await maxBridge.GetSnapshotAsync(cancellationToken);
return new MaxBrowserSnapshotDto(snapshot.Url, snapshot.Title, snapshot.BodyText, snapshot.ScreenshotPngBase64, snapshot.CapturedAt);
}
[HttpPost("sync")]
public async Task<ActionResult<object>> Sync(CancellationToken cancellationToken)
{
var changed = await syncService.SyncOnceAsync(cancellationToken);
return new { changed };
}
private async Task SaveStateAsync(MaxBridgeStatus status, CancellationToken cancellationToken)
{
var state = await db.MaxAccountStates.FirstOrDefaultAsync(x => x.Id == 1, cancellationToken);
if (state is null)
{
state = new MaxAccountState { Id = 1 };
db.MaxAccountStates.Add(state);
}
state.PhoneNumber = _options.MaxPhoneNumber;
state.Status = status.Status;
state.IsAuthorized = status.IsAuthorized;
state.LastUrl = status.Url;
state.LastError = status.LastError;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
private static MaxBridgeStatusDto ToDto(MaxBridgeStatus status)
{
return new MaxBridgeStatusDto(status.Mode, status.IsAuthorized, status.LoginStage, status.Status, status.Url, status.Title, status.LastError, status.UpdatedAt);
}
}
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Auth;
namespace QMax.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/push")]
public sealed class PushController(QMaxDbContext db) : ControllerBase
{
public sealed record RegisterPushDeviceRequest(string FirebaseToken, string Platform);
[HttpPost("devices")]
public async Task<IActionResult> Register(RegisterPushDeviceRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.FirebaseToken))
{
return BadRequest("firebaseToken is required.");
}
var userId = User.GetUserId();
var device = await db.PushDevices.FirstOrDefaultAsync(x => x.FirebaseToken == request.FirebaseToken, cancellationToken);
if (device is null)
{
device = new PushDevice { FirebaseToken = request.FirebaseToken };
db.PushDevices.Add(device);
}
device.UserId = userId;
device.Platform = string.IsNullOrWhiteSpace(request.Platform) ? "android" : request.Platform;
device.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpDelete("devices/{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
{
var userId = User.GetUserId();
var device = await db.PushDevices.FirstOrDefaultAsync(x => x.Id == id && x.UserId == userId, cancellationToken);
if (device is null)
{
return NotFound();
}
db.PushDevices.Remove(device);
await db.SaveChangesAsync(cancellationToken);
return NoContent();
}
}
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
namespace QMax.Api.Controllers;
[ApiController]
[Route("api/status")]
public sealed class StatusController(QMaxDbContext db) : ControllerBase
{
[AllowAnonymous]
[HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
{
return new
{
name = "QMAX Bridge",
status = "ok",
serverTime = DateTimeOffset.UtcNow,
chats = await db.Chats.CountAsync(cancellationToken),
messages = await db.Messages.CountAsync(cancellationToken)
};
}
}
@@ -0,0 +1,74 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using QMax.Api.Configuration;
using QMax.Api.Data.Entities;
namespace QMax.Api.Infrastructure.Auth;
public interface ITokenService
{
string CreateAccessToken(User user, UserSession session, DateTimeOffset expiresAt);
string CreateRefreshToken();
string HashRefreshToken(string refreshToken);
}
public sealed class TokenService(IOptions<QMaxOptions> options) : ITokenService
{
private readonly QMaxOptions _options = options.Value;
public string CreateAccessToken(User user, UserSession session, DateTimeOffset expiresAt)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(GetJwtSecret()));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new Claim("sid", session.Id.ToString()),
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.DisplayName)
};
var token = new JwtSecurityToken(
issuer: _options.JwtIssuer,
audience: _options.JwtAudience,
claims: claims,
notBefore: DateTime.UtcNow,
expires: expiresAt.UtcDateTime,
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
public string CreateRefreshToken()
{
Span<byte> bytes = stackalloc byte[48];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes);
}
public string HashRefreshToken(string refreshToken)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken));
return Convert.ToHexString(bytes);
}
private string GetJwtSecret()
{
if (!string.IsNullOrWhiteSpace(_options.JwtSecret) && _options.JwtSecret.Length >= 32)
{
return _options.JwtSecret;
}
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
{
return "development-only-qmax-secret-change-before-production";
}
throw new InvalidOperationException("QMax:JwtSecret must be set to at least 32 characters in production.");
}
}
@@ -0,0 +1,20 @@
using System.Security.Claims;
namespace QMax.Api.Infrastructure.Auth;
public static class UserContext
{
public static Guid GetUserId(this ClaimsPrincipal principal)
{
var value = principal.FindFirstValue(ClaimTypes.NameIdentifier)
?? principal.FindFirstValue("sub")
?? throw new InvalidOperationException("Authenticated user id is missing.");
return Guid.Parse(value);
}
public static Guid? GetSessionId(this ClaimsPrincipal principal)
{
var value = principal.FindFirstValue("sid");
return Guid.TryParse(value, out var id) ? id : null;
}
}
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace QMax.Api.Infrastructure.Hubs;
[Authorize]
public sealed class QMaxHub : Hub
{
public Task JoinChat(string chatId)
{
return Groups.AddToGroupAsync(Context.ConnectionId, $"chat:{chatId}");
}
public Task LeaveChat(string chatId)
{
return Groups.RemoveFromGroupAsync(Context.ConnectionId, $"chat:{chatId}");
}
}
@@ -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();
}
}
}
+238
View File
@@ -0,0 +1,238 @@
using System.Text;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using QMax.Api.Configuration;
using QMax.Api.Data;
using QMax.Api.Infrastructure.Auth;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<QMaxOptions>(builder.Configuration.GetSection("QMax"));
var configuredOptions = builder.Configuration.GetSection("QMax").Get<QMaxOptions>() ?? new QMaxOptions();
var databaseDirectory = Path.GetDirectoryName(Path.GetFullPath(configuredOptions.DatabasePath));
if (!string.IsNullOrWhiteSpace(databaseDirectory))
{
Directory.CreateDirectory(databaseDirectory);
}
builder.Services.AddDbContext<QMaxDbContext>(options =>
{
options.UseSqlite($"Data Source={configuredOptions.DatabasePath}");
});
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var origins = configuredOptions.CorsAllowedOrigins
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
if (origins.Length > 0)
{
policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
}
else
{
policy.AllowAnyHeader().AllowAnyMethod().SetIsOriginAllowed(_ => true).AllowCredentials();
}
});
});
var jwtSecret = !string.IsNullOrWhiteSpace(configuredOptions.JwtSecret) && configuredOptions.JwtSecret.Length >= 32
? configuredOptions.JwtSecret
: builder.Environment.IsDevelopment()
? "development-only-qmax-secret-change-before-production"
: throw new InvalidOperationException("QMax:JwtSecret must be set to at least 32 characters in production.");
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = configuredOptions.JwtIssuer,
ValidAudience = configuredOptions.JwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecret)),
ClockSkew = TimeSpan.FromMinutes(1)
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs/qmax"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
builder.Services.AddAuthorization();
builder.Services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
builder.Services.AddSignalR();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<ITokenService, TokenService>();
builder.Services.AddScoped<ChatProjectionService>();
builder.Services.AddScoped<IPushNotificationService, FirebasePushNotificationService>();
builder.Services.AddSingleton<IAttachmentStorageService, AttachmentStorageService>();
builder.Services.AddSingleton<MaxBridgeSyncService>();
builder.Services.AddHostedService<MaxSyncWorker>();
if (string.Equals(configuredOptions.MaxMode, "Mock", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddSingleton<IMaxBridgeClient, MockMaxBridgeClient>();
}
else
{
builder.Services.AddHttpClient<IMaxBridgeClient, WorkerMaxBridgeClient>();
}
var app = builder.Build();
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<QMaxHub>("/hubs/qmax");
using (var scope = app.Services.CreateScope())
{
var options = scope.ServiceProvider.GetRequiredService<IOptions<QMaxOptions>>().Value;
Directory.CreateDirectory(options.StoragePath);
Directory.CreateDirectory(options.ReleasesPath);
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
await db.Database.EnsureCreatedAsync();
await EnsureCompatibilitySchemaAsync(db);
}
app.Run();
static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db)
{
var connection = db.Database.GetDbConnection();
if (connection.State != System.Data.ConnectionState.Open)
{
await connection.OpenAsync();
}
var attachmentColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA table_info(MessageAttachments);";
await using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
attachmentColumns.Add(reader.GetString(1));
}
}
if (!attachmentColumns.Contains("RemoteUrl"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MessageAttachments ADD COLUMN RemoteUrl TEXT;");
}
if (!attachmentColumns.Contains("ExternalId"))
{
await db.Database.ExecuteSqlRawAsync("ALTER TABLE MessageAttachments ADD COLUMN ExternalId TEXT;");
}
await db.Database.ExecuteSqlRawAsync("""
CREATE INDEX IF NOT EXISTS IX_MessageAttachments_MessageId_ExternalId
ON MessageAttachments (MessageId, ExternalId);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS MessageReactions (
Id TEXT NOT NULL CONSTRAINT PK_MessageReactions PRIMARY KEY,
MessageId TEXT NOT NULL,
Emoji TEXT NOT NULL,
ActorKey TEXT NOT NULL,
ActorName TEXT NULL,
CreatedAt TEXT NOT NULL,
CONSTRAINT FK_MessageReactions_Messages_MessageId FOREIGN KEY (MessageId) REFERENCES Messages (Id) ON DELETE CASCADE
);
""");
await db.Database.ExecuteSqlRawAsync("""
CREATE UNIQUE INDEX IF NOT EXISTS IX_MessageReactions_MessageId_ActorKey
ON MessageReactions (MessageId, ActorKey);
""");
await RemoveGenericMediaLabelsAsync(db);
}
static async Task RemoveGenericMediaLabelsAsync(QMaxDbContext db)
{
var placeholderMessages = await db.Messages
.Where(message => !db.MessageAttachments.Any(attachment => attachment.MessageId == message.Id))
.ToListAsync();
foreach (var message in placeholderMessages.Where(message => MessageTextSanitizer.IsGenericMediaLabel(message.Text)))
{
db.Messages.Remove(message);
}
var messages = await db.Messages.ToListAsync();
foreach (var message in messages)
{
var cleanText = MessageTextSanitizer.CleanMessageText(
message.Text,
await db.MessageAttachments.AnyAsync(attachment => attachment.MessageId == message.Id));
if (!string.Equals(message.Text, cleanText, StringComparison.Ordinal))
{
message.Text = cleanText;
}
}
var chats = await db.Chats.ToListAsync();
foreach (var chat in chats)
{
var cleanPreview = MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview);
if (!string.Equals(chat.LastMessagePreview, cleanPreview, StringComparison.Ordinal))
{
chat.LastMessagePreview = cleanPreview;
}
}
var attachments = await db.MessageAttachments.ToListAsync();
foreach (var attachment in attachments)
{
var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(
attachment.OriginalFileName,
attachment.ContentType,
attachment.Kind);
if (!string.Equals(attachment.OriginalFileName, normalizedFileName, StringComparison.Ordinal))
{
attachment.OriginalFileName = normalizedFileName;
}
}
await db.SaveChangesAsync();
}
public partial class Program;
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5197",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7265;http://localhost:5197",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@QMax.Api_HostAddress = http://localhost:5197
GET {{QMax.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,75 @@
using QMax.Api.Contracts;
using QMax.Api.Data.Entities;
namespace QMax.Api.Services;
public sealed class ChatProjectionService
{
public ChatDto ToDto(Chat chat)
{
return new ChatDto(
chat.Id,
chat.ExternalId,
chat.Kind,
chat.Title,
AvatarPath(chat),
CleanPreview(chat.LastMessagePreview),
chat.LastMessageAt,
chat.UnreadCount,
chat.IsPinned,
chat.IsMuted,
chat.IsArchived);
}
public MessageDto ToDto(Message message)
{
return new MessageDto(
message.Id,
message.ChatId,
message.ExternalId,
message.SenderName,
message.Direction,
MessageTextSanitizer.CleanMessageText(message.Text, message.Attachments.Count != 0),
message.SentAt,
message.EditedAt,
message.DeletedAt,
message.ReplyToMessageId,
message.ForwardedFrom,
message.DeliveryState,
message.Error,
message.Reactions
.GroupBy(x => x.Emoji)
.OrderByDescending(x => x.Count())
.ThenBy(x => x.Key, StringComparer.Ordinal)
.Select(x => new ReactionDto(x.Key, x.Count(), x.Any(r => r.ActorKey == "self")))
.ToArray(),
message.Attachments
.OrderBy(x => x.SortOrder)
.Select(x => ToDto(message.ChatId, x))
.ToArray());
}
private static AttachmentDto ToDto(Guid chatId, MessageAttachment attachment)
{
return new AttachmentDto(
attachment.Id,
attachment.OriginalFileName,
attachment.ContentType,
attachment.FileSizeBytes,
$"/api/chats/{chatId}/attachments/{attachment.Id}/content",
attachment.Kind,
attachment.SortOrder);
}
private static string? CleanPreview(string? value)
{
return MessageTextSanitizer.CleanChatPreview(value);
}
private static string? AvatarPath(Chat chat)
{
return string.IsNullOrWhiteSpace(chat.AvatarUrl)
? null
: $"/api/chats/{chat.Id}/avatar";
}
}
@@ -0,0 +1,324 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
namespace QMax.Api.Services;
public sealed class FirebasePushNotificationService(
QMaxDbContext db,
IHttpClientFactory httpClientFactory,
IOptions<QMaxOptions> options,
ILogger<FirebasePushNotificationService> logger) : IPushNotificationService
{
private const string FirebaseScope = "https://www.googleapis.com/auth/firebase.messaging";
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly SemaphoreSlim TokenLock = new(1, 1);
private static string? cachedAccessToken;
private static DateTimeOffset cachedAccessTokenExpiresAt;
public async Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken)
{
var qmax = options.Value;
if (!qmax.PushEnabled || chat.IsMuted)
{
return;
}
var serviceAccount = await LoadServiceAccountAsync(qmax, cancellationToken);
if (serviceAccount is null)
{
logger.LogDebug("FCM push is enabled but Firebase service account is not configured.");
return;
}
var projectId = FirstNonBlank(qmax.FirebaseProjectId, serviceAccount.ProjectId);
if (string.IsNullOrWhiteSpace(projectId))
{
logger.LogWarning("FCM push is enabled but Firebase project id is missing.");
return;
}
var devices = await db.PushDevices
.Where(x => !string.IsNullOrWhiteSpace(x.FirebaseToken))
.ToArrayAsync(cancellationToken);
if (devices.Length == 0)
{
return;
}
var accessToken = await GetAccessTokenAsync(serviceAccount, cancellationToken);
var title = chat.Title;
var body = qmax.PushShowPreview
? BuildMessagePreview(message)
: "Новое сообщение";
if (IsPresencePreview(body))
{
return;
}
var data = new Dictionary<string, string>
{
["type"] = "new_message",
["title"] = title,
["body"] = body,
["chatId"] = chat.Id.ToString(),
["messageId"] = message.Id.ToString(),
["chatTitle"] = chat.Title
};
foreach (var device in devices)
{
await SendToDeviceAsync(projectId, accessToken, device, title, body, data, cancellationToken);
}
await db.SaveChangesAsync(cancellationToken);
}
private async Task SendToDeviceAsync(
string projectId,
string accessToken,
PushDevice device,
string title,
string body,
IReadOnlyDictionary<string, string> data,
CancellationToken cancellationToken)
{
var payload = new
{
message = new
{
token = device.FirebaseToken,
data,
android = new
{
priority = "HIGH"
}
}
};
using var request = new HttpRequestMessage(
HttpMethod.Post,
$"https://fcm.googleapis.com/v1/projects/{Uri.EscapeDataString(projectId)}/messages:send");
request.Headers.Authorization = new("Bearer", accessToken);
request.Content = new StringContent(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
using var response = await httpClientFactory.CreateClient().SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (response.IsSuccessStatusCode)
{
return;
}
if (IsInvalidFirebaseToken(response.StatusCode, responseBody))
{
logger.LogInformation("Removing invalid FCM token for device {DeviceId}.", device.Id);
db.PushDevices.Remove(device);
return;
}
logger.LogWarning(
"FCM send failed for device {DeviceId} with {Status}: {Body}",
device.Id,
response.StatusCode,
responseBody);
}
private static async Task<FirebaseServiceAccount?> LoadServiceAccountAsync(QMaxOptions qmax, CancellationToken cancellationToken)
{
var json = qmax.FirebaseServiceAccountJson;
if (string.IsNullOrWhiteSpace(json) && !string.IsNullOrWhiteSpace(qmax.FirebaseServiceAccountPath))
{
if (!File.Exists(qmax.FirebaseServiceAccountPath))
{
return null;
}
json = await File.ReadAllTextAsync(qmax.FirebaseServiceAccountPath, cancellationToken);
}
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
return JsonSerializer.Deserialize<FirebaseServiceAccount>(json, JsonOptions);
}
private async Task<string> GetAccessTokenAsync(FirebaseServiceAccount serviceAccount, CancellationToken cancellationToken)
{
if (!string.IsNullOrWhiteSpace(cachedAccessToken) &&
cachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2))
{
return cachedAccessToken;
}
await TokenLock.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(cachedAccessToken) &&
cachedAccessTokenExpiresAt > DateTimeOffset.UtcNow.AddMinutes(2))
{
return cachedAccessToken;
}
var assertion = CreateServiceAccountAssertion(serviceAccount);
using var request = new HttpRequestMessage(HttpMethod.Post, serviceAccount.TokenUri)
{
Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer",
["assertion"] = assertion
})
};
using var response = await httpClientFactory.CreateClient().SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
response.EnsureSuccessStatusCode();
var token = JsonSerializer.Deserialize<GoogleAccessTokenResponse>(body, JsonOptions)
?? throw new InvalidOperationException("Google OAuth token response was empty.");
cachedAccessToken = token.AccessToken;
cachedAccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(60, token.ExpiresIn - 60));
return cachedAccessToken;
}
finally
{
TokenLock.Release();
}
}
private static string CreateServiceAccountAssertion(FirebaseServiceAccount serviceAccount)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var header = Base64Url(JsonSerializer.SerializeToUtf8Bytes(new
{
alg = "RS256",
typ = "JWT"
}));
var claim = Base64Url(JsonSerializer.SerializeToUtf8Bytes(new
{
iss = serviceAccount.ClientEmail,
scope = FirebaseScope,
aud = serviceAccount.TokenUri,
iat = now,
exp = now + 3600
}));
var signingInput = $"{header}.{claim}";
using var rsa = RSA.Create();
rsa.ImportFromPem(serviceAccount.PrivateKey);
var signature = rsa.SignData(
Encoding.ASCII.GetBytes(signingInput),
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
return $"{signingInput}.{Base64Url(signature)}";
}
private static string BuildMessagePreview(Message message)
{
if (!string.IsNullOrWhiteSpace(message.Text))
{
return message.Text.Length <= 120
? message.Text
: message.Text[..117] + "...";
}
return message.Attachments.Count > 0 ? "Медиа" : "Новое сообщение";
}
private static bool IsPresencePreview(string? value)
{
var text = value?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(text) || text.Length > 120)
{
return false;
}
text = text
.Replace("...", "")
.Replace("\u2026", "")
.Trim();
if (text.Contains("\u043f\u0435\u0447\u0430\u0442", StringComparison.Ordinal) ||
text.Contains("\u043d\u0430\u0431\u0438\u0440\u0430", StringComparison.Ordinal) ||
text.Contains("typing", StringComparison.Ordinal))
{
return true;
}
var hasProcessVerb =
text.Contains("\u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("\u0432\u044b\u0431\u0438\u0440\u0430\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("\u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u044f\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("is sending", StringComparison.Ordinal) ||
text.Contains("sending", StringComparison.Ordinal) ||
text.Contains("choosing", StringComparison.Ordinal) ||
text.Contains("uploading", StringComparison.Ordinal) ||
text.Contains("recording", StringComparison.Ordinal);
if (!hasProcessVerb)
{
return false;
}
return text.Contains("\u0444\u043e\u0442\u043e", StringComparison.Ordinal) ||
text.Contains("\u0438\u0437\u043e\u0431\u0440\u0430\u0436", StringComparison.Ordinal) ||
text.Contains("\u043a\u0430\u0440\u0442\u0438\u043d", StringComparison.Ordinal) ||
text.Contains("\u0432\u0438\u0434\u0435\u043e", StringComparison.Ordinal) ||
text.Contains("\u0441\u0442\u0438\u043a\u0435\u0440", StringComparison.Ordinal) ||
text.Contains("\u044d\u043c\u043e\u0434\u0437\u0438", StringComparison.Ordinal) ||
text.Contains("\u0433\u043e\u043b\u043e\u0441", StringComparison.Ordinal) ||
text.Contains("\u0430\u0443\u0434\u0438\u043e", StringComparison.Ordinal) ||
text.Contains("\u0444\u0430\u0439\u043b", StringComparison.Ordinal) ||
text.Contains("photo", StringComparison.Ordinal) ||
text.Contains("image", StringComparison.Ordinal) ||
text.Contains("video", StringComparison.Ordinal) ||
text.Contains("sticker", StringComparison.Ordinal) ||
text.Contains("emoji", StringComparison.Ordinal) ||
text.Contains("voice", StringComparison.Ordinal) ||
text.Contains("audio", StringComparison.Ordinal) ||
text.Contains("file", StringComparison.Ordinal);
}
private static bool IsInvalidFirebaseToken(HttpStatusCode statusCode, string responseBody)
{
if (statusCode is not (HttpStatusCode.BadRequest or HttpStatusCode.NotFound))
{
return false;
}
return responseBody.Contains("UNREGISTERED", StringComparison.OrdinalIgnoreCase) ||
responseBody.Contains("registration token", StringComparison.OrdinalIgnoreCase);
}
private static string FirstNonBlank(params string?[] values)
{
return values.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x)) ?? "";
}
private static string Base64Url(byte[] bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private sealed record FirebaseServiceAccount(
[property: JsonPropertyName("project_id")] string ProjectId,
[property: JsonPropertyName("client_email")] string ClientEmail,
[property: JsonPropertyName("private_key")] string PrivateKey,
[property: JsonPropertyName("token_uri")] string TokenUri);
private sealed record GoogleAccessTokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
}
@@ -0,0 +1,8 @@
using QMax.Api.Data.Entities;
namespace QMax.Api.Services;
public interface IPushNotificationService
{
Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken);
}
@@ -0,0 +1,690 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Hubs;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using System.Text;
namespace QMax.Api.Services;
public sealed class MaxBridgeSyncService(
IServiceScopeFactory scopeFactory,
IMaxBridgeClient maxBridgeClient,
IHubContext<QMaxHub> hubContext,
ILogger<MaxBridgeSyncService> logger)
{
public async Task<int> SyncOnceAsync(CancellationToken cancellationToken)
{
try
{
var updates = await maxBridgeClient.FetchUpdatesAsync(cancellationToken);
return await ApplyUpdatesAsync(updates, incrementUnread: true, sendPushNotifications: true, cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "MAX sync failed.");
return 0;
}
}
public async Task<int> SyncChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(externalChatId))
{
return 0;
}
try
{
var update = await maxBridgeClient.FetchChatHistoryAsync(externalChatId, cancellationToken);
return update is null
? 0
: await ApplyUpdatesAsync([update], incrementUnread: false, sendPushNotifications: false, cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "MAX chat history sync failed for {ExternalChatId}.", externalChatId);
return 0;
}
}
private async Task<int> ApplyUpdatesAsync(
IReadOnlyList<MaxChatUpdate> updates,
bool incrementUnread,
bool sendPushNotifications,
CancellationToken cancellationToken)
{
if (updates.Count == 0)
{
return 0;
}
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var projection = scope.ServiceProvider.GetRequiredService<ChatProjectionService>();
var pushNotifications = scope.ServiceProvider.GetRequiredService<IPushNotificationService>();
var storage = scope.ServiceProvider.GetRequiredService<IAttachmentStorageService>();
var changed = 0;
var createdMessages = new List<(Chat Chat, Message Message)>();
var updatedMessages = new List<(Chat Chat, Message Message)>();
var previewPushNotifications = new List<(Chat Chat, Message Message)>();
foreach (var update in updates)
{
var chat = await db.Chats
.FirstOrDefaultAsync(x => x.ExternalId == update.ExternalId, cancellationToken);
if (chat is null && update.ExternalId.StartsWith("dom:", StringComparison.OrdinalIgnoreCase))
{
chat = await db.Chats
.FirstOrDefaultAsync(
x => x.Title == update.Title &&
x.ExternalId != null &&
x.ExternalId.StartsWith("dom:"),
cancellationToken);
}
var chatWasCreated = chat is null;
if (chat is null)
{
chat = new Chat
{
ExternalId = update.ExternalId,
Title = update.Title,
AvatarUrl = update.AvatarUrl,
Kind = ChatKind.MaxDialog
};
db.Chats.Add(chat);
changed++;
}
var nextAvatarUrl = string.IsNullOrWhiteSpace(update.AvatarUrl)
? chat.AvatarUrl
: update.AvatarUrl;
var metadataChanged = false;
if (!string.Equals(chat.ExternalId, update.ExternalId, StringComparison.Ordinal))
{
chat.ExternalId = update.ExternalId;
metadataChanged = true;
}
if (!string.Equals(chat.Title, update.Title, StringComparison.Ordinal))
{
chat.Title = update.Title;
metadataChanged = true;
}
if (!string.Equals(chat.AvatarUrl, nextAvatarUrl, StringComparison.Ordinal))
{
chat.AvatarUrl = nextAvatarUrl;
metadataChanged = true;
}
if (metadataChanged)
{
chat.UpdatedAt = DateTimeOffset.UtcNow;
changed++;
}
if (ApplyListPreview(
update,
chat,
chatWasCreated,
incrementUnread,
sendPushNotifications,
previewPushNotifications))
{
changed++;
}
foreach (var incoming in update.Messages)
{
if (IsPreviewOnlyMessage(incoming.ExternalId))
{
continue;
}
if (IsPresencePreview(incoming.Text) && (incoming.Attachments?.Count ?? 0) == 0)
{
continue;
}
var existing = await db.Messages
.Include(x => x.Attachments)
.FirstOrDefaultAsync(x => x.ExternalId == incoming.ExternalId, cancellationToken);
if (existing is not null)
{
var merge = await MergeExistingMessageAsync(
db,
existing,
incoming,
maxBridgeClient,
storage,
cancellationToken);
if (merge.Changed)
{
updatedMessages.Add((chat, existing));
if (chat.LastMessageAt is null || existing.SentAt >= chat.LastMessageAt)
{
chat.LastMessagePreview = LastMessagePreview(existing.Text, existing.Attachments.Concat(merge.AddedAttachments));
chat.LastMessageAt = existing.SentAt;
}
changed++;
}
continue;
}
var message = new Message
{
Chat = chat,
ExternalId = incoming.ExternalId,
SenderExternalId = incoming.SenderExternalId,
SenderName = incoming.SenderName,
Direction = incoming.IsOutgoing ? MessageDirection.Outgoing : MessageDirection.Incoming,
Text = MessageTextSanitizer.CleanMessageText(
incoming.Text,
incoming.Attachments?.Count > 0),
SentAt = incoming.SentAt,
DeliveryState = ResolveDeliveryState(incoming)
};
foreach (var incomingAttachment in incoming.Attachments ?? [])
{
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
message.Attachments.Add(attachment);
}
db.Messages.Add(message);
createdMessages.Add((chat, message));
chat.LastMessagePreview = LastMessagePreview(incoming.Text, message.Attachments);
chat.LastMessageAt = incoming.SentAt;
if (incrementUnread && !incoming.IsOutgoing)
{
chat.UnreadCount++;
}
changed++;
}
}
if (changed > 0)
{
await db.SaveChangesAsync(cancellationToken);
foreach (var (chat, message) in createdMessages)
{
var dto = projection.ToDto(message);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageCreated", dto, cancellationToken);
if (sendPushNotifications && message.Direction == MessageDirection.Incoming)
{
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
}
}
foreach (var (chat, message) in previewPushNotifications)
{
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
}
foreach (var (chat, message) in updatedMessages)
{
var updated = await db.Messages
.Include(x => x.Attachments)
.Include(x => x.Reactions)
.FirstOrDefaultAsync(x => x.Id == message.Id, cancellationToken);
if (updated is not null)
{
var dto = projection.ToDto(updated);
await hubContext.Clients.Group($"chat:{chat.Id}").SendAsync("MessageUpdated", dto, cancellationToken);
}
}
await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken);
}
return changed;
}
private static bool ApplyListPreview(
MaxChatUpdate update,
Chat chat,
bool chatWasCreated,
bool incrementUnread,
bool sendPushNotifications,
ICollection<(Chat Chat, Message Message)> previewPushNotifications)
{
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
if (string.IsNullOrWhiteSpace(preview))
{
return false;
}
if (IsPresencePreview(preview))
{
return false;
}
var previousPreview = chat.LastMessagePreview;
if (string.Equals(previousPreview, preview, StringComparison.Ordinal))
{
return false;
}
var previewAt = ResolveListPreviewAt(update);
chat.LastMessagePreview = preview;
chat.LastMessageAt = previewAt;
chat.UpdatedAt = DateTimeOffset.UtcNow;
var shouldNotify = !chatWasCreated &&
!string.IsNullOrWhiteSpace(previousPreview) &&
update.LastMessageIsOutgoing != true &&
update.Messages.Count == 0 &&
IsFreshListPreview(update, previewAt);
if (incrementUnread && shouldNotify)
{
chat.UnreadCount++;
}
if (sendPushNotifications && shouldNotify)
{
previewPushNotifications.Add((
chat,
new Message
{
Id = Guid.NewGuid(),
ChatId = chat.Id,
Chat = chat,
Direction = MessageDirection.Incoming,
Text = preview,
SentAt = previewAt,
DeliveryState = MessageDeliveryState.Delivered,
SenderExternalId = chat.ExternalId,
SenderName = chat.Title
}));
}
return true;
}
private static DateTimeOffset ResolveListPreviewAt(MaxChatUpdate update)
{
return TryParseMoscowListTime(update.LastMessageTimeText, out var parsed)
? parsed
: update.LastMessageAt ?? update.UpdatedAt;
}
private static bool IsFreshListPreview(MaxChatUpdate update, DateTimeOffset previewAt)
{
if (string.IsNullOrWhiteSpace(update.LastMessageTimeText))
{
return false;
}
var now = DateTimeOffset.UtcNow;
return previewAt >= now.AddMinutes(-15) && previewAt <= now.AddMinutes(10);
}
private static bool TryParseMoscowListTime(string? value, out DateTimeOffset parsed)
{
parsed = default;
var text = value?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var match = System.Text.RegularExpressions.Regex.Match(text, @"\b(?<hour>\d{1,2}):(?<minute>\d{2})\b");
if (!match.Success)
{
return false;
}
if (!int.TryParse(match.Groups["hour"].Value, out var hour) ||
!int.TryParse(match.Groups["minute"].Value, out var minute) ||
hour is < 0 or > 23 ||
minute is < 0 or > 59)
{
return false;
}
var zone = GetMoscowTimeZone();
var nowMoscow = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, zone);
var local = new DateTime(nowMoscow.Year, nowMoscow.Month, nowMoscow.Day, hour, minute, 0, DateTimeKind.Unspecified);
parsed = new DateTimeOffset(local, zone.GetUtcOffset(local));
if (parsed > DateTimeOffset.UtcNow.AddMinutes(10))
{
parsed = parsed.AddDays(-1);
}
return true;
}
private static TimeZoneInfo GetMoscowTimeZone()
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById("Europe/Moscow");
}
catch (TimeZoneNotFoundException)
{
return TimeZoneInfo.FindSystemTimeZoneById("Russian Standard Time");
}
}
private static bool IsPreviewOnlyMessage(string? externalId)
{
return externalId?.StartsWith("dommsg:", StringComparison.OrdinalIgnoreCase) == true;
}
private static bool IsPresencePreview(string? value)
{
var text = value?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(text) || text.Length > 120)
{
return false;
}
text = text
.Replace("...", "")
.Replace("\u2026", "")
.Trim();
if (text.Contains("\u043f\u0435\u0447\u0430\u0442", StringComparison.Ordinal) ||
text.Contains("\u043d\u0430\u0431\u0438\u0440\u0430", StringComparison.Ordinal) ||
text.Contains("typing", StringComparison.Ordinal))
{
return true;
}
var hasProcessVerb =
text.Contains("\u043e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("\u0432\u044b\u0431\u0438\u0440\u0430\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("\u0437\u0430\u0433\u0440\u0443\u0436\u0430\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u044f\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("\u0437\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0435\u0442", StringComparison.Ordinal) ||
text.Contains("is sending", StringComparison.Ordinal) ||
text.Contains("sending", StringComparison.Ordinal) ||
text.Contains("choosing", StringComparison.Ordinal) ||
text.Contains("uploading", StringComparison.Ordinal) ||
text.Contains("recording", StringComparison.Ordinal);
if (!hasProcessVerb)
{
return false;
}
return text.Contains("\u0444\u043e\u0442\u043e", StringComparison.Ordinal) ||
text.Contains("\u0438\u0437\u043e\u0431\u0440\u0430\u0436", StringComparison.Ordinal) ||
text.Contains("\u043a\u0430\u0440\u0442\u0438\u043d", StringComparison.Ordinal) ||
text.Contains("\u0432\u0438\u0434\u0435\u043e", StringComparison.Ordinal) ||
text.Contains("\u0441\u0442\u0438\u043a\u0435\u0440", StringComparison.Ordinal) ||
text.Contains("\u044d\u043c\u043e\u0434\u0437\u0438", StringComparison.Ordinal) ||
text.Contains("\u0433\u043e\u043b\u043e\u0441", StringComparison.Ordinal) ||
text.Contains("\u0430\u0443\u0434\u0438\u043e", StringComparison.Ordinal) ||
text.Contains("\u0444\u0430\u0439\u043b", StringComparison.Ordinal) ||
text.Contains("photo", StringComparison.Ordinal) ||
text.Contains("image", StringComparison.Ordinal) ||
text.Contains("video", StringComparison.Ordinal) ||
text.Contains("sticker", StringComparison.Ordinal) ||
text.Contains("emoji", StringComparison.Ordinal) ||
text.Contains("voice", StringComparison.Ordinal) ||
text.Contains("audio", StringComparison.Ordinal) ||
text.Contains("file", StringComparison.Ordinal);
}
private sealed record MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments);
private static async Task<MessageMergeResult> MergeExistingMessageAsync(
QMaxDbContext db,
Message message,
MaxMessageUpdate incoming,
IMaxBridgeClient maxBridgeClient,
IAttachmentStorageService storage,
CancellationToken cancellationToken)
{
var changed = false;
var metadataChanged = false;
var addedAttachments = new List<MessageAttachment>();
var knownAttachments = message.Attachments.ToList();
var incomingAttachments = incoming.Attachments ?? [];
var cleanText = MessageTextSanitizer.CleanMessageText(
incoming.Text,
incomingAttachments.Count > 0 || message.Attachments.Count > 0);
if (!string.Equals(message.Text, cleanText, StringComparison.Ordinal))
{
message.Text = cleanText;
changed = true;
metadataChanged = true;
}
var senderName = string.IsNullOrWhiteSpace(incoming.SenderName) ? null : incoming.SenderName;
if (!string.Equals(message.SenderName, senderName, StringComparison.Ordinal))
{
message.SenderName = senderName;
changed = true;
metadataChanged = true;
}
if (!string.Equals(message.SenderExternalId, incoming.SenderExternalId, StringComparison.Ordinal))
{
message.SenderExternalId = incoming.SenderExternalId;
changed = true;
metadataChanged = true;
}
var direction = incoming.IsOutgoing ? MessageDirection.Outgoing : MessageDirection.Incoming;
if (message.Direction != direction)
{
message.Direction = direction;
changed = true;
metadataChanged = true;
}
if (message.SentAt != incoming.SentAt)
{
message.SentAt = incoming.SentAt;
changed = true;
metadataChanged = true;
}
var deliveryState = ResolveDeliveryState(incoming);
if (message.DeliveryState != deliveryState)
{
message.DeliveryState = deliveryState;
changed = true;
metadataChanged = true;
}
if (metadataChanged)
{
await db.Messages
.Where(x => x.Id == message.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Text, cleanText)
.SetProperty(x => x.SenderName, senderName)
.SetProperty(x => x.SenderExternalId, incoming.SenderExternalId)
.SetProperty(x => x.Direction, direction)
.SetProperty(x => x.SentAt, incoming.SentAt)
.SetProperty(x => x.DeliveryState, deliveryState),
cancellationToken);
db.Entry(message).State = EntityState.Unchanged;
}
foreach (var incomingAttachment in incomingAttachments)
{
if (HasAttachment(knownAttachments, incomingAttachment))
{
continue;
}
var attachment = await CreateAttachmentAsync(incomingAttachment, maxBridgeClient, storage, cancellationToken);
attachment.MessageId = message.Id;
db.MessageAttachments.Add(attachment);
knownAttachments.Add(attachment);
addedAttachments.Add(attachment);
changed = true;
}
return new MessageMergeResult(changed, addedAttachments);
}
private static async Task<MessageAttachment> CreateAttachmentAsync(
MaxAttachmentUpdate incoming,
IMaxBridgeClient maxBridgeClient,
IAttachmentStorageService storage,
CancellationToken cancellationToken)
{
var kind = ResolveKind(incoming);
var remoteUrl = incoming.RemoteUrl;
if (!string.IsNullOrWhiteSpace(incoming.TextContent))
{
var contentType = string.IsNullOrWhiteSpace(incoming.ContentType)
? "text/vcard; charset=utf-8"
: incoming.ContentType;
var bytes = Encoding.UTF8.GetBytes(incoming.TextContent);
await using var stream = new MemoryStream(bytes);
var stored = await storage.SaveRemoteAsync(
incoming.FileName,
contentType,
stream,
bytes.Length,
kind,
cancellationToken);
return new MessageAttachment
{
ExternalId = incoming.ExternalId,
OriginalFileName = stored.OriginalFileName,
StorageFileName = stored.StorageFileName,
ContentType = stored.ContentType,
FileSizeBytes = stored.FileSizeBytes,
Sha256 = stored.Sha256,
Kind = stored.Kind,
SortOrder = incoming.SortOrder,
RemoteUrl = remoteUrl
};
}
if (!string.IsNullOrWhiteSpace(remoteUrl))
{
try
{
var download = await maxBridgeClient.DownloadMediaAsync(remoteUrl, cancellationToken);
if (download is not null)
{
await using (download.Stream)
{
var stored = await storage.SaveRemoteAsync(
incoming.FileName,
download.ContentType,
download.Stream,
download.ContentLength ?? incoming.FileSizeBytes,
kind,
cancellationToken);
return new MessageAttachment
{
ExternalId = incoming.ExternalId,
OriginalFileName = stored.OriginalFileName,
StorageFileName = stored.StorageFileName,
ContentType = stored.ContentType,
FileSizeBytes = stored.FileSizeBytes,
Sha256 = stored.Sha256,
Kind = stored.Kind,
SortOrder = incoming.SortOrder,
RemoteUrl = remoteUrl
};
}
}
}
catch
{
// Keep the message visible even if a specific remote media item cannot be cached.
}
}
return new MessageAttachment
{
ExternalId = incoming.ExternalId,
OriginalFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind),
StorageFileName = $"remote-{Guid.NewGuid():N}",
ContentType = string.IsNullOrWhiteSpace(incoming.ContentType) ? "application/octet-stream" : incoming.ContentType,
FileSizeBytes = incoming.FileSizeBytes ?? 0,
Kind = kind ?? AttachmentKind.File,
SortOrder = incoming.SortOrder,
RemoteUrl = remoteUrl
};
}
private static bool HasAttachment(IEnumerable<MessageAttachment> attachments, MaxAttachmentUpdate incoming)
{
if (!string.IsNullOrWhiteSpace(incoming.ExternalId) &&
attachments.Any(x => string.Equals(x.ExternalId, incoming.ExternalId, StringComparison.Ordinal)))
{
return true;
}
if (!string.IsNullOrWhiteSpace(incoming.RemoteUrl) &&
attachments.Any(x => string.Equals(x.RemoteUrl, incoming.RemoteUrl, StringComparison.Ordinal)))
{
return true;
}
var kind = ResolveKind(incoming) ?? AttachmentKind.File;
var normalizedFileName = AttachmentStorageService.NormalizeRemoteFileName(incoming.FileName, incoming.ContentType, kind);
return attachments.Any(x =>
x.SortOrder == incoming.SortOrder &&
x.Kind == kind &&
string.Equals(x.OriginalFileName, normalizedFileName, StringComparison.OrdinalIgnoreCase));
}
private static AttachmentKind? ResolveKind(MaxAttachmentUpdate attachment)
{
if (!string.IsNullOrWhiteSpace(attachment.Kind) &&
Enum.TryParse<AttachmentKind>(attachment.Kind, ignoreCase: true, out var parsed))
{
return parsed;
}
var extension = Path.GetExtension(attachment.FileName);
return AttachmentStorageService.GuessKind(attachment.ContentType, extension);
}
private static MessageDeliveryState ResolveDeliveryState(MaxMessageUpdate incoming)
{
if (!incoming.IsOutgoing)
{
return MessageDeliveryState.Delivered;
}
return Enum.TryParse<MessageDeliveryState>(incoming.DeliveryState, ignoreCase: true, out var parsed)
? parsed
: MessageDeliveryState.Sent;
}
private static string? LastMessagePreview(string? text, IEnumerable<MessageAttachment> attachments)
{
var orderedAttachments = attachments.OrderBy(x => x.SortOrder).ToArray();
if (!string.IsNullOrWhiteSpace(text) &&
(orderedAttachments.Length == 0 || !MessageTextSanitizer.IsGenericAttachmentLabel(text)))
{
return MessageTextSanitizer.CleanChatPreview(text);
}
var first = orderedAttachments.FirstOrDefault();
return first?.Kind switch
{
AttachmentKind.Image => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.Gif => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.Video => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.VoiceNote => "\u0413\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435 \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
AttachmentKind.Sticker => "\u041c\u0435\u0434\u0438\u0430",
AttachmentKind.Contact => "\u041a\u043e\u043d\u0442\u0430\u043a\u0442",
AttachmentKind.File => first.OriginalFileName,
_ => null
};
}
}
+39
View File
@@ -0,0 +1,39 @@
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
namespace QMax.Api.Services;
public sealed class MaxSyncWorker(
IOptions<QMaxOptions> options,
MaxBridgeSyncService syncService,
ILogger<MaxSyncWorker> logger) : BackgroundService
{
private readonly QMaxOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var delay = TimeSpan.FromSeconds(Math.Max(3, _options.MaxPollIntervalSeconds));
using var timer = new PeriodicTimer(delay);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await syncService.SyncOnceAsync(stoppingToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "QMAX background sync tick failed.");
}
try
{
await timer.WaitForNextTickAsync(stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
}
@@ -0,0 +1,98 @@
namespace QMax.Api.Services;
public static class MessageTextSanitizer
{
public static string? CleanChatPreview(string? value)
{
if (IsGenericMediaLabel(value))
{
return "\u041c\u0435\u0434\u0438\u0430";
}
return IsQuestionMarkArtifact(value)
? "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435"
: value;
}
public static string? CleanMessageText(string? value, bool hasAttachments)
{
if (hasAttachments && IsGenericAttachmentLabel(value))
{
return null;
}
if (!IsQuestionMarkArtifact(value))
{
return value;
}
return hasAttachments
? null
: "\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435";
}
public static bool IsGenericMediaLabel(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"\u0444\u043e\u0442\u043e" => true,
"photo" => true,
"image" => true,
"gif" => true,
"\u0432\u0438\u0434\u0435\u043e" => true,
"video" => true,
"\u0430\u0443\u0434\u0438\u043e" => true,
"audio" => true,
"\u0433\u043e\u043b\u043e\u0441\u043e\u0432\u043e\u0435" => true,
"voice" => true,
"\u0441\u0442\u0438\u043a\u0435\u0440" => true,
"sticker" => true,
"\u044d\u043c\u043e\u0434\u0437\u0438" => true,
"emoji" => true,
"\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0435\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e" => true,
"\u043f\u0440\u0438\u043a\u0440\u0435\u043f\u043b\u0451\u043d\u043d\u044b\u0435 \u0444\u043e\u0442\u043e" => true,
"attached photos" => true,
_ => false
};
}
public static bool IsGenericAttachmentLabel(string? value)
{
if (IsGenericMediaLabel(value))
{
return true;
}
return value?.Trim().ToLowerInvariant() switch
{
"\u0441\u043a\u0430\u0447\u0430\u0442\u044c" => true,
"download" => true,
"\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c" => true,
"file" => true,
"\u0444\u0430\u0439\u043b" => true,
"document" => true,
"\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442" => true,
"contact" => true,
"\u043a\u043e\u043d\u0442\u0430\u043a\u0442" => true,
_ => false
};
}
public static bool IsQuestionMarkArtifact(string? value)
{
var text = value?.Trim();
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
var questionMarks = text.Count(c => c is '?' or '\uFFFD');
if (questionMarks < 4)
{
return false;
}
var meaningfulCharacters = text.Count(c => char.IsLetterOrDigit(c) && c != '?');
return meaningfulCharacters == 0;
}
}
@@ -0,0 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning"
}
},
"QMax": {
"PairingCode": "qmax-local-dev",
"MaxMode": "Mock"
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"QMax": {
"PublicBaseUrl": "https://qmax.kusoft.xyz",
"DatabasePath": "data/qmax.db",
"StoragePath": "data/storage",
"JwtIssuer": "QMAX",
"JwtAudience": "QMAX.Android",
"JwtSecret": "",
"PairingCode": "",
"MaxPhoneNumber": "",
"MaxMode": "Worker",
"MaxWorkerBaseUrl": "http://qmax-max-worker:3001",
"MaxUserDataPath": "data/max-profile",
"MaxHeadless": true,
"MaxPollIntervalSeconds": 6,
"MaxUploadBytes": 26214400,
"CorsAllowedOrigins": "",
"ReleasesPath": "data/releases",
"PushEnabled": true,
"PushShowPreview": true,
"FirebaseProjectId": "",
"FirebaseServiceAccountPath": "",
"FirebaseServiceAccountJson": ""
}
}