Initial QMAX production app
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user