aba4173827
Share template keys across email and push with independent editors and revisions. Render localized actions and preserve version-one delivery. Ref: IT-1627
176 lines
8.2 KiB
C#
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.")
|
|
};
|
|
}
|