namespace HrynCo.NotificationService.Worker.Services.Push; using FirebaseAdmin.Messaging; using FirebaseAdmin; using Google.Apis.Auth.OAuth2; using System.Text; using System.Text.Json; using HrynCo.NotificationService.DAL.Abstract.Push; using HrynCo.NotificationService.DAL.Abstract.PushChannels; public sealed class FirebasePushClient : IFirebasePushClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; public async Task SendAsync(Guid messageId, RenderedPushMessageData data, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) { if (!PushMessageValidator.IsSupportedVersion(data.Delivery.Version)) return new PushDeliveryOutcome("Unsupported push payload version. Supported versions are 1 and 2."); var remaining = data.Delivery.ExpiresAt - DateTimeOffset.UtcNow; if (remaining <= TimeSpan.Zero) return new PushDeliveryOutcome("Push expired."); var payload = BuildPayload(messageId, data); if (data.Delivery.Version == 2 && Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(payload)) > 3500) return new PushDeliveryOutcome("Rendered push payload is too large."); return await SendMessageAsync(new Message { Token = data.Delivery.Token, Android = new AndroidConfig { Priority = Priority.High, TimeToLive = remaining }, Data = payload }, credentials, cancellationToken); } internal static Dictionary BuildPayload(Guid messageId, RenderedPushMessageData data) { var payload = BasePayload(messageId, data.Delivery.BindingId, data.Delivery.ExpiresAt, data.Title, data.Body); switch (data.Delivery.Version) { case 1: payload["version"] = "1"; payload["route"] = data.Delivery.Route; break; case 2: AddActions(payload, data.DefaultAction, data.Buttons); break; default: throw new ArgumentOutOfRangeException(nameof(data), "Unsupported push payload version."); } return payload; } private static Dictionary BasePayload(Guid messageId, Guid bindingId, DateTimeOffset expiresAt, string title, string body) => new() { ["messageId"] = messageId.ToString("D"), ["bindingId"] = bindingId.ToString("D"), ["title"] = title, ["body"] = body, ["expiresAt"] = expiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) }; private static void AddActions(Dictionary payload, RenderedPushAction? defaultAction, IReadOnlyList buttons) { payload["version"] = "2"; payload["actions"] = JsonSerializer.Serialize(new { defaultAction, buttons }, JsonOptions); } public Task SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) { if (message.Validate() is { } error) return Task.FromResult(new PushDeliveryOutcome(error)); return SendMessageAsync(BuildTestMessage(message), credentials, cancellationToken); } internal static Message BuildTestMessage(PushChannelTestMessage test) { if (test.Validate() is { } error) throw new ArgumentException(error, nameof(test)); const string title = "Notification Service test"; const string body = "This is a test notification from the channel administration page."; var message = new Message { Token = test.Token, Android = new AndroidConfig { Priority = Priority.High, TimeToLive = TimeSpan.FromMinutes(5) } }; if (test.Version == 1) message.Notification = new Notification { Title = title, Body = body }; else { var payload = BasePayload(Guid.NewGuid(), test.BindingId!.Value, DateTimeOffset.UtcNow.AddMinutes(5), title, body); AddActions(payload, new RenderedPushAction(test.ActionKey!), [new RenderedPushAction(test.ActionKey!, Label: test.ButtonLabel)]); message.Data = payload; } return message; } public async Task CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(30)); try { var credential = CreateCredential(credentials).CreateScoped("https://www.googleapis.com/auth/firebase.messaging"); await credential.UnderlyingCredential.GetAccessTokenForRequestAsync(cancellationToken: timeout.Token); return new PushDeliveryOutcome(null); } catch (Google.Apis.Auth.OAuth2.Responses.TokenResponseException) { return new PushDeliveryOutcome("Firebase OAuth authorization failed."); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return new PushDeliveryOutcome("Firebase authorization check timed out."); } catch (HttpRequestException) { return new PushDeliveryOutcome("Could not reach Firebase authorization service."); } } private static GoogleCredential CreateCredential(FirebaseServiceAccountCredentials credentials) => GoogleCredential.FromServiceAccountCredential(new ServiceAccountCredential( new ServiceAccountCredential.Initializer(credentials.ClientEmail) { ProjectId = credentials.ProjectId }.FromPrivateKey(credentials.PrivateKey))); private static async Task SendMessageAsync(Message message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(TimeSpan.FromSeconds(30)); FirebaseApp? app = null; try { app = FirebaseApp.Create(new AppOptions { ProjectId = credentials.ProjectId, Credential = CreateCredential(credentials) }, "push-" + Guid.NewGuid().ToString("N")); await FirebaseMessaging.GetMessaging(app).SendAsync(message, timeout.Token); return new PushDeliveryOutcome(null); } catch (FirebaseMessagingException exception) { var header = exception.HttpResponse?.Headers.RetryAfter; var retryAfter = header?.Delta ?? (header?.Date - DateTimeOffset.UtcNow) ?? TimeSpan.Zero; return Classify(exception.MessagingErrorCode, retryAfter); } catch (Google.Apis.Auth.OAuth2.Responses.TokenResponseException) { return new PushDeliveryOutcome("FCM authorization failed."); } catch (FirebaseException) { return new PushDeliveryOutcome("FCM configuration failed."); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new PushRetryException(TimeSpan.FromMinutes(1)); } catch (HttpRequestException) { throw new PushRetryException(TimeSpan.FromMinutes(1)); } finally { app?.Delete(); } } internal static PushDeliveryOutcome Classify(MessagingErrorCode? code, TimeSpan retryAfter = default) => code switch { MessagingErrorCode.Unregistered => new PushDeliveryOutcome("FCM registration is no longer valid.", true), MessagingErrorCode.Unavailable or MessagingErrorCode.Internal or MessagingErrorCode.QuotaExceeded => throw new PushRetryException(retryAfter), MessagingErrorCode.SenderIdMismatch => new PushDeliveryOutcome("FCM sender configuration does not match."), MessagingErrorCode.InvalidArgument => new PushDeliveryOutcome("FCM rejected the push payload."), _ => new PushDeliveryOutcome("FCM authorization or configuration failure.") }; }