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 options, ILogger 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 { ["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 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 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(json, JsonOptions); } private async Task 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 { ["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(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); }