Files
QMAX/server/QMax.Api/Services/FirebasePushNotificationService.cs
2026-07-04 21:47:06 +03:00

332 lines
13 KiB
C#

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 (message.Direction != MessageDirection.Incoming)
{
logger.LogDebug("Skipping push for non-incoming message {MessageId} in chat {ChatId}.", message.Id, chat.Id);
return;
}
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,
["direction"] = message.Direction.ToString()
};
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);
}