332 lines
13 KiB
C#
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);
|
|
}
|