Files
hrynco-notification-service/HrynCo.NotificationService.Worker.Services/Push/FirebasePushClient.cs
T
agrynco aba4173827 feat(templates): add localized push templates and actions
Share template keys across email and push with independent editors and revisions. Render localized actions and preserve version-one delivery.

Ref: IT-1627
2026-10-31 09:38:05 +02:00

176 lines
8.2 KiB
C#

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<PushDeliveryOutcome> 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<string, string> 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<string, string> 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<string, string> payload, RenderedPushAction? defaultAction, IReadOnlyList<RenderedPushAction> buttons)
{
payload["version"] = "2";
payload["actions"] = JsonSerializer.Serialize(new { defaultAction, buttons }, JsonOptions);
}
public Task<PushDeliveryOutcome> 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<PushDeliveryOutcome> 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<PushDeliveryOutcome> 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.")
};
}