💥 Rename Pusher to Ring

This commit is contained in:
2025-09-14 19:42:51 +08:00
parent 4ee387ab76
commit 5c97733b3e
52 changed files with 308 additions and 308 deletions

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using DysonNetwork.Shared.Data;
using NodaTime;
namespace DysonNetwork.Ring.Notification;
public class Notification : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(1024)] public string Topic { get; set; } = null!;
[MaxLength(1024)] public string? Title { get; set; }
[MaxLength(2048)] public string? Subtitle { get; set; }
[MaxLength(4096)] public string? Content { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object?> Meta { get; set; } = new();
public int Priority { get; set; } = 10;
public Instant? ViewedAt { get; set; }
public Guid AccountId { get; set; }
}

View File

@@ -0,0 +1,163 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Proto;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Ring.Notification;
[ApiController]
[Route("/api/notifications")]
public class NotificationController(
AppDatabase db,
PushService nty
) : ControllerBase
{
[HttpGet("count")]
[Authorize]
public async Task<ActionResult<int>> CountUnreadNotifications()
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var count = await db.Notifications
.Where(s => s.AccountId == accountId && s.ViewedAt == null)
.CountAsync();
return Ok(count);
}
[HttpGet]
[Authorize]
public async Task<ActionResult<List<Notification>>> ListNotifications(
[FromQuery] int offset = 0,
// The page size set to 5 is to avoid the client pulled the notification
// but didn't render it in the screen-viewable region.
[FromQuery] int take = 8
)
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var totalCount = await db.Notifications
.Where(s => s.AccountId == accountId)
.CountAsync();
var notifications = await db.Notifications
.Where(s => s.AccountId == accountId)
.OrderByDescending(e => e.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
Response.Headers["X-Total"] = totalCount.ToString();
await nty.MarkNotificationsViewed(notifications.ToList());
return Ok(notifications);
}
[HttpPost("all/read")]
[Authorize]
public async Task<ActionResult> MarkAllNotificationsViewed()
{
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
if (currentUserValue is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
await nty.MarkAllNotificationsViewed(accountId);
return Ok();
}
public class PushNotificationSubscribeRequest
{
[MaxLength(4096)] public string DeviceToken { get; set; } = null!;
public PushProvider Provider { get; set; }
}
[HttpPut("subscription")]
[Authorize]
public async Task<ActionResult<PushSubscription>>
SubscribeToPushNotification(
[FromBody] PushNotificationSubscribeRequest request
)
{
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as AuthSession;
if (currentSession == null) return Unauthorized();
var result =
await nty.SubscribeDevice(
currentSession.Challenge.DeviceId,
request.DeviceToken,
request.Provider,
currentUser
);
return Ok(result);
}
[HttpDelete("subscription")]
[Authorize]
public async Task<ActionResult<int>> UnsubscribeFromPushNotification()
{
HttpContext.Items.TryGetValue("CurrentSession", out var currentSessionValue);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account;
if (currentUser == null) return Unauthorized();
var currentSession = currentSessionValue as AuthSession;
if (currentSession == null) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
var affectedRows = await db.PushSubscriptions
.Where(s =>
s.AccountId == accountId &&
s.DeviceId == currentSession.Challenge.DeviceId
).ExecuteDeleteAsync();
return Ok(affectedRows);
}
public class NotificationRequest
{
[Required][MaxLength(1024)] public string Topic { get; set; } = null!;
[Required][MaxLength(1024)] public string Title { get; set; } = null!;
[MaxLength(2048)] public string? Subtitle { get; set; }
[Required][MaxLength(4096)] public string Content { get; set; } = null!;
public Dictionary<string, object?>? Meta { get; set; }
public int Priority { get; set; } = 10;
}
public class NotificationWithAimRequest : NotificationRequest
{
[Required] public List<Guid> AccountId { get; set; } = null!;
}
[HttpPost("send")]
[Authorize]
[RequiredPermission("global", "notifications.send")]
public async Task<ActionResult> SendNotification(
[FromBody] NotificationWithAimRequest request,
[FromQuery] bool save = false
)
{
await nty.SendNotificationBatch(
new Notification
{
CreatedAt = SystemClock.Instance.GetCurrentInstant(),
UpdatedAt = SystemClock.Instance.GetCurrentInstant(),
Topic = request.Topic,
Title = request.Title,
Subtitle = request.Subtitle,
Content = request.Content,
Meta = request.Meta ?? [],
},
request.AccountId,
save
);
return Ok();
}
}

View File

@@ -0,0 +1,27 @@
using DysonNetwork.Shared.Cache;
using EFCore.BulkExtensions;
using NodaTime;
using Quartz;
namespace DysonNetwork.Ring.Notification;
public class NotificationFlushHandler(AppDatabase db) : IFlushHandler<Notification>
{
public async Task FlushAsync(IReadOnlyList<Notification> items)
{
await db.BulkInsertAsync(items.Select(x =>
{
x.CreatedAt = SystemClock.Instance.GetCurrentInstant();
x.UpdatedAt = x.CreatedAt;
return x;
}), config => config.ConflictOption = ConflictOption.Ignore);
}
}
public class NotificationFlushJob(FlushBufferService fbs, NotificationFlushHandler hdl) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
await fbs.FlushAsync(hdl);
}
}

View File

@@ -0,0 +1,385 @@
using CorePush.Apple;
using CorePush.Firebase;
using DysonNetwork.Ring.Connection;
using DysonNetwork.Ring.Services;
using DysonNetwork.Shared.Proto;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using WebSocketPacket = DysonNetwork.Ring.Connection.WebSocketPacket;
namespace DysonNetwork.Ring.Notification;
public class PushService
{
private readonly AppDatabase _db;
private readonly WebSocketService _ws;
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,
WebSocketService ws,
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;
_ws = ws;
_queueService = queueService;
_logger = logger;
}
public async Task UnsubscribeDevice(string deviceId)
{
await _db.PushSubscriptions
.Where(s => s.DeviceId == deviceId)
.ExecuteDeleteAsync();
}
public async Task<PushSubscription> 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 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 PushSubscription
{
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 = Guid.Parse(account.Id!);
var notification = new Notification
{
Topic = topic,
Title = title,
Subtitle = subtitle,
Content = content,
Meta = meta,
AccountId = accountId,
};
if (save)
{
_db.Notifications.Add(notification);
await _db.SaveChangesAsync();
}
if (!isSilent)
_ = _queueService.EnqueuePushNotification(notification, accountId, save);
}
public async Task DeliverPushNotification(Notification notification, CancellationToken cancellationToken = default)
{
_ws.SendPacketToAccount(notification.AccountId.ToString(), 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<Notification> 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(Notification notification, List<Guid> accounts, bool save = false)
{
if (save)
{
var now = SystemClock.Instance.GetCurrentInstant();
var notifications = accounts.Select(accountId => new Notification
{
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; // keep original behavior
_ws.SendPacketToAccount(account.ToString(), new Connection.WebSocketPacket
{
Type = "notifications.new",
Data = notification
});
}
await DeliverPushNotification(notification);
}
private async Task SendPushNotificationAsync(PushSubscription subscription, Notification 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(Notification notification)
{
_db.Notifications.Add(notification);
await _db.SaveChangesAsync();
}
public async Task SaveNotification(Notification notification, List<Guid> accounts)
{
_db.Notifications.AddRange(accounts.Select(a => new Notification
{
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();
}
}

View File

@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Shared.Data;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Ring.Notification;
public enum PushProvider
{
Apple,
Google
}
[Index(nameof(AccountId), nameof(DeviceId), nameof(DeletedAt), IsUnique = true)]
public class PushSubscription : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid AccountId { get; set; }
[MaxLength(8192)] public string DeviceId { get; set; } = null!;
[MaxLength(8192)] public string DeviceToken { get; set; } = null!;
public PushProvider Provider { get; set; }
public int CountDelivered { get; set; }
public Instant? LastUsedAt { get; set; }
}