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