383 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			383 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using CorePush.Apple;
 | 
						|
using CorePush.Firebase;
 | 
						|
using DysonNetwork.Ring.Connection;
 | 
						|
using DysonNetwork.Ring.Services;
 | 
						|
using DysonNetwork.Shared.Models;
 | 
						|
using DysonNetwork.Shared.Proto;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using NodaTime;
 | 
						|
using WebSocketPacket = DysonNetwork.Shared.Models.WebSocketPacket;
 | 
						|
 | 
						|
namespace DysonNetwork.Ring.Notification;
 | 
						|
 | 
						|
public class PushService
 | 
						|
{
 | 
						|
    private readonly AppDatabase _db;
 | 
						|
    private readonly QueueService _queueService;
 | 
						|
    private readonly ILogger<PushService> _logger;
 | 
						|
    private readonly FirebaseSender? _fcm;
 | 
						|
    private readonly ApnSender? _apns;
 | 
						|
    private readonly string? _apnsTopic;
 | 
						|
 | 
						|
    public PushService(
 | 
						|
        IConfiguration config,
 | 
						|
        AppDatabase db,
 | 
						|
        QueueService queueService,
 | 
						|
        IHttpClientFactory httpFactory,
 | 
						|
        ILogger<PushService> logger
 | 
						|
    )
 | 
						|
    {
 | 
						|
        var cfgSection = config.GetSection("Notifications:Push");
 | 
						|
 | 
						|
        // Set up Firebase Cloud Messaging
 | 
						|
        var fcmConfig = cfgSection.GetValue<string>("Google");
 | 
						|
        if (fcmConfig != null && File.Exists(fcmConfig))
 | 
						|
            _fcm = new FirebaseSender(File.ReadAllText(fcmConfig), httpFactory.CreateClient());
 | 
						|
 | 
						|
        // Set up Apple Push Notification Service
 | 
						|
        var apnsKeyPath = cfgSection.GetValue<string>("Apple:PrivateKey");
 | 
						|
        if (apnsKeyPath != null && File.Exists(apnsKeyPath))
 | 
						|
        {
 | 
						|
            _apns = new ApnSender(new ApnSettings
 | 
						|
            {
 | 
						|
                P8PrivateKey = File.ReadAllText(apnsKeyPath),
 | 
						|
                P8PrivateKeyId = cfgSection.GetValue<string>("Apple:PrivateKeyId"),
 | 
						|
                TeamId = cfgSection.GetValue<string>("Apple:TeamId"),
 | 
						|
                AppBundleIdentifier = cfgSection.GetValue<string>("Apple:BundleIdentifier"),
 | 
						|
                ServerType = cfgSection.GetValue<bool>("Production")
 | 
						|
                    ? ApnServerType.Production
 | 
						|
                    : ApnServerType.Development
 | 
						|
            }, httpFactory.CreateClient());
 | 
						|
            _apnsTopic = cfgSection.GetValue<string>("Apple:BundleIdentifier");
 | 
						|
        }
 | 
						|
 | 
						|
        _db = db;
 | 
						|
        _queueService = queueService;
 | 
						|
        _logger = logger;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task UnsubscribeDevice(string deviceId)
 | 
						|
    {
 | 
						|
        await _db.PushSubscriptions
 | 
						|
            .Where(s => s.DeviceId == deviceId)
 | 
						|
            .ExecuteDeleteAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<SnNotificationPushSubscription> SubscribeDevice(
 | 
						|
        string deviceId,
 | 
						|
        string deviceToken,
 | 
						|
        PushProvider provider,
 | 
						|
        Account account
 | 
						|
    )
 | 
						|
    {
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var accountId = Guid.Parse(account.Id);
 | 
						|
 | 
						|
        // Check for existing subscription with the same device ID or token
 | 
						|
        var existingSubscription = await _db.PushSubscriptions
 | 
						|
            .Where(s => s.AccountId == accountId)
 | 
						|
            .Where(s => s.DeviceId == deviceId || s.DeviceToken == deviceToken)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
 | 
						|
        if (existingSubscription != null)
 | 
						|
        {
 | 
						|
            existingSubscription.DeviceId = deviceId;
 | 
						|
            existingSubscription.DeviceToken = deviceToken;
 | 
						|
            existingSubscription.Provider = provider;
 | 
						|
            existingSubscription.UpdatedAt = now;
 | 
						|
 | 
						|
            _db.Update(existingSubscription);
 | 
						|
            await _db.SaveChangesAsync();
 | 
						|
            return existingSubscription;
 | 
						|
        }
 | 
						|
 | 
						|
        var subscription = new SnNotificationPushSubscription
 | 
						|
        {
 | 
						|
            DeviceId = deviceId,
 | 
						|
            DeviceToken = deviceToken,
 | 
						|
            Provider = provider,
 | 
						|
            AccountId = accountId,
 | 
						|
            CreatedAt = now,
 | 
						|
            UpdatedAt = now
 | 
						|
        };
 | 
						|
 | 
						|
        _db.PushSubscriptions.Add(subscription);
 | 
						|
        await _db.SaveChangesAsync();
 | 
						|
 | 
						|
        return subscription;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task SendNotification(Account account,
 | 
						|
        string topic,
 | 
						|
        string? title = null,
 | 
						|
        string? subtitle = null,
 | 
						|
        string? content = null,
 | 
						|
        Dictionary<string, object?>? meta = null,
 | 
						|
        string? actionUri = null,
 | 
						|
        bool isSilent = false,
 | 
						|
        bool save = true)
 | 
						|
    {
 | 
						|
        meta ??= [];
 | 
						|
        if (title is null && subtitle is null && content is null)
 | 
						|
            throw new ArgumentException("Unable to send notification that is completely empty.");
 | 
						|
 | 
						|
        if (actionUri is not null) meta["action_uri"] = actionUri;
 | 
						|
 | 
						|
        var accountId = account.Id;
 | 
						|
        var notification = new SnNotification
 | 
						|
        {
 | 
						|
            Topic = topic,
 | 
						|
            Title = title,
 | 
						|
            Subtitle = subtitle,
 | 
						|
            Content = content,
 | 
						|
            Meta = meta,
 | 
						|
            AccountId = Guid.Parse(accountId),
 | 
						|
        };
 | 
						|
 | 
						|
        if (save)
 | 
						|
        {
 | 
						|
            _db.Notifications.Add(notification);
 | 
						|
            await _db.SaveChangesAsync();
 | 
						|
        }
 | 
						|
 | 
						|
        if (!isSilent)
 | 
						|
            _ = _queueService.EnqueuePushNotification(notification, Guid.Parse(accountId), save);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task DeliverPushNotification(SnNotification notification, CancellationToken cancellationToken = default)
 | 
						|
    {
 | 
						|
        WebSocketService.SendPacketToAccount(notification.AccountId, new WebSocketPacket()
 | 
						|
        {
 | 
						|
            Type = "notifications.new",
 | 
						|
            Data = notification,
 | 
						|
        });
 | 
						|
        
 | 
						|
        try
 | 
						|
        {
 | 
						|
            _logger.LogInformation(
 | 
						|
                "Delivering push notification: {NotificationTopic} with meta {NotificationMeta}",
 | 
						|
                notification.Topic,
 | 
						|
                notification.Meta
 | 
						|
            );
 | 
						|
 | 
						|
            // Get all push subscriptions for the account
 | 
						|
            var subscriptions = await _db.PushSubscriptions
 | 
						|
                .Where(s => s.AccountId == notification.AccountId)
 | 
						|
                .ToListAsync(cancellationToken);
 | 
						|
 | 
						|
            if (subscriptions.Count == 0)
 | 
						|
            {
 | 
						|
                _logger.LogInformation("No push subscriptions found for account {AccountId}", notification.AccountId);
 | 
						|
                return;
 | 
						|
            }
 | 
						|
 | 
						|
            // Send push notifications
 | 
						|
            var tasks = new List<Task>();
 | 
						|
            foreach (var subscription in subscriptions)
 | 
						|
            {
 | 
						|
                try
 | 
						|
                {
 | 
						|
                    tasks.Add(SendPushNotificationAsync(subscription, notification));
 | 
						|
                }
 | 
						|
                catch (Exception ex)
 | 
						|
                {
 | 
						|
                    _logger.LogError(ex, "Error sending push notification to {DeviceId}", subscription.DeviceId);
 | 
						|
                }
 | 
						|
            }
 | 
						|
 | 
						|
            await Task.WhenAll(tasks);
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            _logger.LogError(ex, "Error in DeliverPushNotification");
 | 
						|
            throw;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task MarkNotificationsViewed(ICollection<SnNotification> notifications)
 | 
						|
    {
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var id = notifications.Where(n => n.ViewedAt == null).Select(n => n.Id).ToList();
 | 
						|
        if (id.Count == 0) return;
 | 
						|
 | 
						|
        await _db.Notifications
 | 
						|
            .Where(n => id.Contains(n.Id))
 | 
						|
            .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now));
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task MarkAllNotificationsViewed(Guid accountId)
 | 
						|
    {
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        await _db.Notifications
 | 
						|
            .Where(n => n.AccountId == accountId)
 | 
						|
            .Where(n => n.ViewedAt == null)
 | 
						|
            .ExecuteUpdateAsync(s => s.SetProperty(n => n.ViewedAt, now));
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task SendNotificationBatch(SnNotification notification, List<Guid> accounts, bool save = false)
 | 
						|
    {
 | 
						|
        if (save)
 | 
						|
        {
 | 
						|
            var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
            var notifications = accounts.Select(accountId => new SnNotification
 | 
						|
            {
 | 
						|
                Topic = notification.Topic,
 | 
						|
                Title = notification.Title,
 | 
						|
                Subtitle = notification.Subtitle,
 | 
						|
                Content = notification.Content,
 | 
						|
                Meta = notification.Meta,
 | 
						|
                Priority = notification.Priority,
 | 
						|
                AccountId = accountId,
 | 
						|
                CreatedAt = now,
 | 
						|
                UpdatedAt = now
 | 
						|
            }).ToList();
 | 
						|
 | 
						|
            if (notifications.Count != 0)
 | 
						|
            {
 | 
						|
                await _db.Notifications.AddRangeAsync(notifications);
 | 
						|
                await _db.SaveChangesAsync();
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        _logger.LogInformation(
 | 
						|
            "Delivering notification in batch: {NotificationTopic} #{NotificationId} with meta {NotificationMeta}",
 | 
						|
            notification.Topic,
 | 
						|
            notification.Id,
 | 
						|
            notification.Meta
 | 
						|
        );
 | 
						|
 | 
						|
        // WS first
 | 
						|
        foreach (var account in accounts)
 | 
						|
        {
 | 
						|
            notification.AccountId = account;
 | 
						|
            WebSocketService.SendPacketToAccount(account, new WebSocketPacket
 | 
						|
            {
 | 
						|
                Type = "notifications.new",
 | 
						|
                Data = notification
 | 
						|
            });
 | 
						|
        }
 | 
						|
 | 
						|
        await DeliverPushNotification(notification);
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task SendPushNotificationAsync(SnNotificationPushSubscription subscription, SnNotification notification)
 | 
						|
    {
 | 
						|
        try
 | 
						|
        {
 | 
						|
            _logger.LogDebug(
 | 
						|
                $"Pushing notification {notification.Topic} #{notification.Id} to device #{subscription.DeviceId}");
 | 
						|
 | 
						|
            switch (subscription.Provider)
 | 
						|
            {
 | 
						|
                case PushProvider.Google:
 | 
						|
                    if (_fcm == null)
 | 
						|
                        throw new InvalidOperationException("Firebase Cloud Messaging is not initialized.");
 | 
						|
 | 
						|
                    var body = string.Empty;
 | 
						|
                    if (!string.IsNullOrEmpty(notification.Subtitle) || !string.IsNullOrEmpty(notification.Content))
 | 
						|
                    {
 | 
						|
                        body = string.Join("\n",
 | 
						|
                            notification.Subtitle ?? string.Empty,
 | 
						|
                            notification.Content ?? string.Empty
 | 
						|
                        ).Trim();
 | 
						|
                    }
 | 
						|
 | 
						|
                    var fcmResult = await _fcm.SendAsync(new Dictionary<string, object>
 | 
						|
                    {
 | 
						|
                        ["message"] = new Dictionary<string, object>
 | 
						|
                        {
 | 
						|
                            ["token"] = subscription.DeviceToken,
 | 
						|
                            ["notification"] = new Dictionary<string, object>
 | 
						|
                            {
 | 
						|
                                ["title"] = notification.Title ?? string.Empty,
 | 
						|
                                ["body"] = body
 | 
						|
                            },
 | 
						|
                            // You can re-enable data payloads if needed.
 | 
						|
                            // ["data"] = new Dictionary<string, object>
 | 
						|
                            // {
 | 
						|
                            //     ["Id"] = notification.Id,
 | 
						|
                            //     ["Topic"] = notification.Topic,
 | 
						|
                            //     ["Meta"] = notification.Meta
 | 
						|
                            // }
 | 
						|
                        }
 | 
						|
                    });
 | 
						|
 | 
						|
                    if (fcmResult.Error != null)
 | 
						|
                        throw new Exception($"Notification pushed failed ({fcmResult.StatusCode}) {fcmResult.Error}");
 | 
						|
                    break;
 | 
						|
 | 
						|
                case PushProvider.Apple:
 | 
						|
                    if (_apns == null)
 | 
						|
                        throw new InvalidOperationException("Apple Push Notification Service is not initialized.");
 | 
						|
 | 
						|
                    var alertDict = new Dictionary<string, object>();
 | 
						|
                    if (!string.IsNullOrEmpty(notification.Title))
 | 
						|
                        alertDict["title"] = notification.Title;
 | 
						|
                    if (!string.IsNullOrEmpty(notification.Subtitle))
 | 
						|
                        alertDict["subtitle"] = notification.Subtitle;
 | 
						|
                    if (!string.IsNullOrEmpty(notification.Content))
 | 
						|
                        alertDict["body"] = notification.Content;
 | 
						|
 | 
						|
                    var payload = new Dictionary<string, object?>
 | 
						|
                    {
 | 
						|
                        ["topic"] = _apnsTopic,
 | 
						|
                        ["type"] = notification.Topic,
 | 
						|
                        ["aps"] = new Dictionary<string, object?>
 | 
						|
                        {
 | 
						|
                            ["alert"] = alertDict,
 | 
						|
                            ["sound"] = notification.Priority >= 5 ? "default" : null,
 | 
						|
                            ["mutable-content"] = 1
 | 
						|
                        },
 | 
						|
                        ["meta"] = notification.Meta
 | 
						|
                    };
 | 
						|
 | 
						|
                    var apnResult = await _apns.SendAsync(
 | 
						|
                        payload,
 | 
						|
                        deviceToken: subscription.DeviceToken,
 | 
						|
                        apnsId: notification.Id.ToString(),
 | 
						|
                        apnsPriority: notification.Priority,
 | 
						|
                        apnPushType: ApnPushType.Alert
 | 
						|
                    );
 | 
						|
                    if (apnResult.Error != null)
 | 
						|
                        throw new Exception($"Notification pushed failed ({apnResult.StatusCode}) {apnResult.Error}");
 | 
						|
 | 
						|
                    break;
 | 
						|
 | 
						|
                default:
 | 
						|
                    throw new InvalidOperationException($"Push provider not supported: {subscription.Provider}");
 | 
						|
            }
 | 
						|
        }
 | 
						|
        catch (Exception ex)
 | 
						|
        {
 | 
						|
            _logger.LogError(ex,
 | 
						|
                $"Failed to push notification #{notification.Id} to device {subscription.DeviceId}. {ex.Message}");
 | 
						|
            // Swallow here to keep worker alive; upstream is fire-and-forget.
 | 
						|
        }
 | 
						|
 | 
						|
        _logger.LogInformation(
 | 
						|
            $"Successfully pushed notification #{notification.Id} to device {subscription.DeviceId} provider {subscription.Provider}");
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task SaveNotification(SnNotification notification)
 | 
						|
    {
 | 
						|
        _db.Notifications.Add(notification);
 | 
						|
        await _db.SaveChangesAsync();
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task SaveNotification(SnNotification notification, List<Guid> accounts)
 | 
						|
    {
 | 
						|
        _db.Notifications.AddRange(accounts.Select(a => new SnNotification
 | 
						|
        {
 | 
						|
            AccountId = a,
 | 
						|
            Topic = notification.Topic,
 | 
						|
            Content = notification.Content,
 | 
						|
            Title = notification.Title,
 | 
						|
            Subtitle = notification.Subtitle,
 | 
						|
            Meta = notification.Meta,
 | 
						|
            Priority = notification.Priority,
 | 
						|
            CreatedAt = notification.CreatedAt,
 | 
						|
            UpdatedAt = notification.UpdatedAt,
 | 
						|
        }));
 | 
						|
        await _db.SaveChangesAsync();
 | 
						|
    }
 | 
						|
} |