Files
hrynco-notification-service/HrynCo.NotificationService.Worker.Services/Push/FirebasePushClient.cs
T
agrynco 1631786ce2 feat(push): add service-owned firebase channels and delivery
Add push contracts, provider delivery and channel diagnostics. Link email channels to services and document migration and validation.

Ref: IT-1074
2026-10-24 12:31:35 +03:00

127 lines
5.8 KiB
C#

namespace HrynCo.NotificationService.Worker.Services.Push;
using FirebaseAdmin.Messaging;
using FirebaseAdmin;
using Google.Apis.Auth.OAuth2;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
public sealed class FirebasePushClient : IFirebasePushClient
{
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data,
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
{
var remaining = data.ExpiresAt - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero) return new PushDeliveryOutcome("Push expired.");
return await SendMessageAsync(new Message
{
Token = data.Token,
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = remaining },
Data = new Dictionary<string, string>
{
["version"] = "1", ["messageId"] = messageId.ToString("D"),
["bindingId"] = data.BindingId.ToString("D"), ["title"] = data.Title,
["body"] = data.Body, ["route"] = data.Route,
["expiresAt"] = data.ExpiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
}
}, credentials, cancellationToken);
}
public Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials,
CancellationToken cancellationToken) => SendMessageAsync(new Message
{
Token = token,
Notification = new Notification
{
Title = "Notification Service test",
Body = "This is a test notification from the channel administration page."
},
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = TimeSpan.FromMinutes(5) }
}, credentials, cancellationToken);
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.")
};
}