Initial commit: IronNotify.Client SDK
Event notification SDK for .NET applications with: - Multi-channel notifications (push, email, SMS, webhooks) - Fluent event builder API - Offline queue with automatic retry - Real-time notifications via SignalR
This commit is contained in:
commit
058d50f99b
|
|
@ -0,0 +1,6 @@
|
|||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
*.DotSettings.user
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
namespace IronNotify.Client
|
||||
{
|
||||
public class Class1
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<!-- NuGet Package Properties -->
|
||||
<PackageId>IronNotify.Client</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>David H Friedel Jr</Authors>
|
||||
<Company>MarketAlly</Company>
|
||||
<Description>Client SDK for IronNotify - Event Notification Platform. Send events, alerts, and notifications from your applications.</Description>
|
||||
<PackageTags>notifications;events;alerts;monitoring;webhooks</PackageTags>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<RepositoryUrl>https://github.com/ironservices/ironnotify-client</RepositoryUrl>
|
||||
<PackageProjectUrl>https://www.ironnotify.com</PackageProjectUrl>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 IronServices
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,335 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IronNotify.Client;
|
||||
|
||||
public class NotifyClient : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly NotifyClientOptions _options;
|
||||
private readonly NotifyOfflineQueue? _offlineQueue;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the offline queue for accessing queued items.
|
||||
/// </summary>
|
||||
public NotifyOfflineQueue? OfflineQueue => _offlineQueue;
|
||||
|
||||
public NotifyClient(NotifyClientOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
if (string.IsNullOrEmpty(options.ApiKey))
|
||||
throw new ArgumentException("API key is required", nameof(options));
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(options.BaseUrl.TrimEnd('/') + "/")
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", options.ApiKey);
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
if (options.Timeout.HasValue)
|
||||
_httpClient.Timeout = options.Timeout.Value;
|
||||
|
||||
// Initialize offline queue if enabled
|
||||
if (options.EnableOfflineQueue)
|
||||
{
|
||||
_offlineQueue = new NotifyOfflineQueue(
|
||||
SendQueuedEventAsync,
|
||||
options.OfflineQueueDirectory,
|
||||
options.MaxOfflineQueueSize,
|
||||
enableAutoRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> SendQueuedEventAsync(QueuedEvent queuedEvent)
|
||||
{
|
||||
var result = await NotifyAsync(queuedEvent.Request, skipQueue: true);
|
||||
return result.Success;
|
||||
}
|
||||
|
||||
public NotifyClient(string apiKey, string? appSlug = null)
|
||||
: this(new NotifyClientOptions { ApiKey = apiKey, DefaultAppSlug = appSlug })
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send an event notification
|
||||
/// </summary>
|
||||
public Task<EventResult> NotifyAsync(NotifyEventRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return NotifyAsync(request, skipQueue: false, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<EventResult> NotifyAsync(NotifyEventRequest request, bool skipQueue, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiRequest = new
|
||||
{
|
||||
appSlug = request.AppSlug ?? _options.DefaultAppSlug,
|
||||
appId = request.AppId,
|
||||
eventType = request.EventType,
|
||||
severity = request.Severity.ToString(),
|
||||
source = request.Source ?? _options.DefaultSource,
|
||||
title = request.Title,
|
||||
message = request.Message,
|
||||
entityId = request.EntityId,
|
||||
metadata = request.Metadata,
|
||||
actions = request.Actions?.Select(a => new
|
||||
{
|
||||
actionId = a.ActionId,
|
||||
label = a.Label,
|
||||
webhookUrl = a.WebhookUrl
|
||||
})
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsJsonAsync("api/v1/events", apiRequest, cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
var result = await response.Content.ReadFromJsonAsync<EventResponse>(cancellationToken: cancellationToken);
|
||||
return new EventResult
|
||||
{
|
||||
Success = true,
|
||||
EventId = result?.Id,
|
||||
Status = result?.Status
|
||||
};
|
||||
}
|
||||
|
||||
var error = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
// Queue for retry if enabled and not already retrying
|
||||
if (!skipQueue && _offlineQueue != null)
|
||||
{
|
||||
_offlineQueue.Enqueue(request);
|
||||
}
|
||||
|
||||
return new EventResult
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
Queued = !skipQueue && _offlineQueue != null
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Queue for retry on network errors
|
||||
if (!skipQueue && _offlineQueue != null)
|
||||
{
|
||||
_offlineQueue.Enqueue(request);
|
||||
}
|
||||
|
||||
return new EventResult
|
||||
{
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
Queued = !skipQueue && _offlineQueue != null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a simple event notification
|
||||
/// </summary>
|
||||
public Task<EventResult> NotifyAsync(
|
||||
string eventType,
|
||||
string title,
|
||||
Severity severity = Severity.Info,
|
||||
string? message = null,
|
||||
Dictionary<string, object>? metadata = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return NotifyAsync(new NotifyEventRequest
|
||||
{
|
||||
EventType = eventType,
|
||||
Title = title,
|
||||
Severity = severity,
|
||||
Message = message,
|
||||
Metadata = metadata
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a fluent event builder
|
||||
/// </summary>
|
||||
public EventBuilder Event(string eventType) => new(this, eventType);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_offlineQueue?.Dispose();
|
||||
_httpClient.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class NotifyClientOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public string BaseUrl { get; set; } = "https://ironnotify.com";
|
||||
public string? DefaultAppSlug { get; set; }
|
||||
public string? DefaultSource { get; set; }
|
||||
public TimeSpan? Timeout { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enable offline queue for failed sends. Default is true.
|
||||
/// </summary>
|
||||
public bool EnableOfflineQueue { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Directory to store offline queue. Defaults to LocalApplicationData/IronNotify/Queue.
|
||||
/// </summary>
|
||||
public string? OfflineQueueDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum items to store in offline queue. Default: 500.
|
||||
/// </summary>
|
||||
public int MaxOfflineQueueSize { get; set; } = 500;
|
||||
}
|
||||
|
||||
public class NotifyEventRequest
|
||||
{
|
||||
public Guid? AppId { get; set; }
|
||||
public string? AppSlug { get; set; }
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public Severity Severity { get; set; } = Severity.Info;
|
||||
public string? Source { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Message { get; set; }
|
||||
public string? EntityId { get; set; }
|
||||
public Dictionary<string, object>? Metadata { get; set; }
|
||||
public List<EventAction>? Actions { get; set; }
|
||||
}
|
||||
|
||||
public class EventAction
|
||||
{
|
||||
public string ActionId { get; set; } = string.Empty;
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string? WebhookUrl { get; set; }
|
||||
}
|
||||
|
||||
public enum Severity
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
|
||||
public class EventResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public Guid? EventId { get; set; }
|
||||
public string? Status { get; set; }
|
||||
public string? Error { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the event was queued for later retry (when offline queue is enabled).
|
||||
/// </summary>
|
||||
public bool Queued { get; set; }
|
||||
}
|
||||
|
||||
internal class EventResponse
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for creating events
|
||||
/// </summary>
|
||||
public class EventBuilder
|
||||
{
|
||||
private readonly NotifyClient _client;
|
||||
private readonly NotifyEventRequest _request;
|
||||
|
||||
internal EventBuilder(NotifyClient client, string eventType)
|
||||
{
|
||||
_client = client;
|
||||
_request = new NotifyEventRequest { EventType = eventType };
|
||||
}
|
||||
|
||||
public EventBuilder WithSeverity(Severity severity)
|
||||
{
|
||||
_request.Severity = severity;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EventBuilder WithTitle(string title)
|
||||
{
|
||||
_request.Title = title;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EventBuilder WithMessage(string message)
|
||||
{
|
||||
_request.Message = message;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EventBuilder WithSource(string source)
|
||||
{
|
||||
_request.Source = source;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EventBuilder WithEntityId(string entityId)
|
||||
{
|
||||
_request.EntityId = entityId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EventBuilder WithApp(string slug)
|
||||
{
|
||||
_request.AppSlug = slug;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EventBuilder WithApp(Guid appId)
|
||||
{
|
||||
_request.AppId = appId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EventBuilder WithMetadata(string key, object value)
|
||||
{
|
||||
_request.Metadata ??= new Dictionary<string, object>();
|
||||
_request.Metadata[key] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EventBuilder WithMetadata(Dictionary<string, object> metadata)
|
||||
{
|
||||
_request.Metadata = metadata;
|
||||
return this;
|
||||
}
|
||||
|
||||
public EventBuilder WithAction(string actionId, string label, string? webhookUrl = null)
|
||||
{
|
||||
_request.Actions ??= new List<EventAction>();
|
||||
_request.Actions.Add(new EventAction
|
||||
{
|
||||
ActionId = actionId,
|
||||
Label = label,
|
||||
WebhookUrl = webhookUrl
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public Task<EventResult> SendAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return _client.NotifyAsync(_request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
|
||||
namespace IronNotify.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time notification client using SignalR for receiving live notifications
|
||||
/// </summary>
|
||||
public class NotifyRealTimeClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _connection;
|
||||
private readonly NotifyRealTimeOptions _options;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a new notification is received
|
||||
/// </summary>
|
||||
public event EventHandler<NotificationReceivedEventArgs>? NotificationReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the unread count changes
|
||||
/// </summary>
|
||||
public event EventHandler<UnreadCountChangedEventArgs>? UnreadCountChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a notification is marked as read
|
||||
/// </summary>
|
||||
public event EventHandler<NotificationReadEventArgs>? NotificationRead;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when an event status changes (acknowledged, resolved)
|
||||
/// </summary>
|
||||
public event EventHandler<EventStatusChangedEventArgs>? EventStatusChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the connection state changes
|
||||
/// </summary>
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Current connection state
|
||||
/// </summary>
|
||||
public HubConnectionState State => _connection.State;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the client is currently connected
|
||||
/// </summary>
|
||||
public bool IsConnected => _connection.State == HubConnectionState.Connected;
|
||||
|
||||
public NotifyRealTimeClient(NotifyRealTimeOptions options)
|
||||
{
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
|
||||
var hubUrl = options.BaseUrl.TrimEnd('/') + "/hubs/notifications";
|
||||
|
||||
_connection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl, httpOptions =>
|
||||
{
|
||||
httpOptions.Headers.Add("Authorization", $"Bearer {options.ApiKey}");
|
||||
})
|
||||
.WithAutomaticReconnect(new RetryPolicy(options.MaxReconnectAttempts))
|
||||
.Build();
|
||||
|
||||
SetupEventHandlers();
|
||||
}
|
||||
|
||||
public NotifyRealTimeClient(string apiKey, string baseUrl = "https://ironnotify.com")
|
||||
: this(new NotifyRealTimeOptions { ApiKey = apiKey, BaseUrl = baseUrl })
|
||||
{
|
||||
}
|
||||
|
||||
private void SetupEventHandlers()
|
||||
{
|
||||
_connection.On<RealTimeNotification>("NotificationReceived", notification =>
|
||||
{
|
||||
NotificationReceived?.Invoke(this, new NotificationReceivedEventArgs(notification));
|
||||
});
|
||||
|
||||
_connection.On<int>("UnreadCountChanged", count =>
|
||||
{
|
||||
UnreadCountChanged?.Invoke(this, new UnreadCountChangedEventArgs(count));
|
||||
});
|
||||
|
||||
_connection.On<NotificationReadPayload>("NotificationRead", payload =>
|
||||
{
|
||||
NotificationRead?.Invoke(this, new NotificationReadEventArgs(payload.EventId, payload.ReadAt));
|
||||
});
|
||||
|
||||
_connection.On<EventStatusPayload>("EventStatusChanged", payload =>
|
||||
{
|
||||
EventStatusChanged?.Invoke(this, new EventStatusChangedEventArgs(payload.EventId, payload.Status, payload.UpdatedAt));
|
||||
});
|
||||
|
||||
_connection.Reconnecting += error =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(
|
||||
HubConnectionState.Reconnecting, error?.Message));
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_connection.Reconnected += connectionId =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(
|
||||
HubConnectionState.Connected, null));
|
||||
|
||||
// Re-subscribe to user notifications after reconnect
|
||||
if (_options.UserId.HasValue)
|
||||
{
|
||||
_ = SubscribeToUserAsync(_options.UserId.Value);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_connection.Closed += error =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(
|
||||
HubConnectionState.Disconnected, error?.Message));
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect to the notification hub
|
||||
/// </summary>
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_connection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _connection.StartAsync(cancellationToken);
|
||||
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(
|
||||
HubConnectionState.Connected, null));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect from the notification hub
|
||||
/// </summary>
|
||||
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_connection.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _connection.StopAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to notifications for a specific user
|
||||
/// </summary>
|
||||
public async Task SubscribeToUserAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureConnectedAsync(cancellationToken);
|
||||
await _connection.InvokeAsync("SubscribeToUser", userId, cancellationToken);
|
||||
_options.UserId = userId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribe from user notifications
|
||||
/// </summary>
|
||||
public async Task UnsubscribeFromUserAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureConnectedAsync(cancellationToken);
|
||||
await _connection.InvokeAsync("UnsubscribeFromUser", userId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to notifications for a specific app
|
||||
/// </summary>
|
||||
public async Task SubscribeToAppAsync(Guid appId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureConnectedAsync(cancellationToken);
|
||||
await _connection.InvokeAsync("SubscribeToApp", appId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribe from app notifications
|
||||
/// </summary>
|
||||
public async Task UnsubscribeFromAppAsync(Guid appId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureConnectedAsync(cancellationToken);
|
||||
await _connection.InvokeAsync("UnsubscribeFromApp", appId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark a notification as read (triggers server-side update and broadcasts to other clients)
|
||||
/// </summary>
|
||||
public async Task MarkAsReadAsync(Guid userId, Guid eventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureConnectedAsync(cancellationToken);
|
||||
await _connection.InvokeAsync("MarkAsRead", userId, eventId, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureConnectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_connection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await ConnectAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
await _connection.DisposeAsync();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private class RetryPolicy : IRetryPolicy
|
||||
{
|
||||
private readonly int _maxAttempts;
|
||||
|
||||
public RetryPolicy(int maxAttempts) => _maxAttempts = maxAttempts;
|
||||
|
||||
public TimeSpan? NextRetryDelay(RetryContext retryContext)
|
||||
{
|
||||
if (retryContext.PreviousRetryCount >= _maxAttempts)
|
||||
return null;
|
||||
|
||||
// Exponential backoff: 0s, 2s, 4s, 8s, 16s, max 30s
|
||||
var delay = Math.Min(Math.Pow(2, retryContext.PreviousRetryCount), 30);
|
||||
return TimeSpan.FromSeconds(delay);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class NotifyRealTimeOptions
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public string BaseUrl { get; set; } = "https://ironnotify.com";
|
||||
public Guid? UserId { get; set; }
|
||||
public int MaxReconnectAttempts { get; set; } = 5;
|
||||
}
|
||||
|
||||
#region Event Args
|
||||
|
||||
public class NotificationReceivedEventArgs : EventArgs
|
||||
{
|
||||
public RealTimeNotification Notification { get; }
|
||||
|
||||
public NotificationReceivedEventArgs(RealTimeNotification notification)
|
||||
{
|
||||
Notification = notification;
|
||||
}
|
||||
}
|
||||
|
||||
public class UnreadCountChangedEventArgs : EventArgs
|
||||
{
|
||||
public int UnreadCount { get; }
|
||||
|
||||
public UnreadCountChangedEventArgs(int unreadCount)
|
||||
{
|
||||
UnreadCount = unreadCount;
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationReadEventArgs : EventArgs
|
||||
{
|
||||
public Guid EventId { get; }
|
||||
public DateTime ReadAt { get; }
|
||||
|
||||
public NotificationReadEventArgs(Guid eventId, DateTime readAt)
|
||||
{
|
||||
EventId = eventId;
|
||||
ReadAt = readAt;
|
||||
}
|
||||
}
|
||||
|
||||
public class EventStatusChangedEventArgs : EventArgs
|
||||
{
|
||||
public Guid EventId { get; }
|
||||
public string Status { get; }
|
||||
public DateTime UpdatedAt { get; }
|
||||
|
||||
public EventStatusChangedEventArgs(Guid eventId, string status, DateTime updatedAt)
|
||||
{
|
||||
EventId = eventId;
|
||||
Status = status;
|
||||
UpdatedAt = updatedAt;
|
||||
}
|
||||
}
|
||||
|
||||
public class ConnectionStateChangedEventArgs : EventArgs
|
||||
{
|
||||
public HubConnectionState State { get; }
|
||||
public string? Error { get; }
|
||||
|
||||
public ConnectionStateChangedEventArgs(HubConnectionState state, string? error)
|
||||
{
|
||||
State = state;
|
||||
Error = error;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DTOs
|
||||
|
||||
public class RealTimeNotification
|
||||
{
|
||||
[JsonPropertyName("eventId")]
|
||||
public Guid EventId { get; set; }
|
||||
|
||||
[JsonPropertyName("deliveryId")]
|
||||
public Guid DeliveryId { get; set; }
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("severity")]
|
||||
public string Severity { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[JsonPropertyName("appId")]
|
||||
public Guid AppId { get; set; }
|
||||
|
||||
[JsonPropertyName("appName")]
|
||||
public string? AppName { get; set; }
|
||||
|
||||
[JsonPropertyName("appSlug")]
|
||||
public string? AppSlug { get; set; }
|
||||
|
||||
[JsonPropertyName("data")]
|
||||
public Dictionary<string, object>? Data { get; set; }
|
||||
|
||||
[JsonPropertyName("actions")]
|
||||
public List<RealTimeAction>? Actions { get; set; }
|
||||
}
|
||||
|
||||
public class RealTimeAction
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("label")]
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
[JsonPropertyName("isPrimary")]
|
||||
public bool IsPrimary { get; set; }
|
||||
}
|
||||
|
||||
internal class NotificationReadPayload
|
||||
{
|
||||
[JsonPropertyName("eventId")]
|
||||
public Guid EventId { get; set; }
|
||||
|
||||
[JsonPropertyName("readAt")]
|
||||
public DateTime ReadAt { get; set; }
|
||||
}
|
||||
|
||||
internal class EventStatusPayload
|
||||
{
|
||||
[JsonPropertyName("eventId")]
|
||||
public Guid EventId { get; set; }
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("updatedAt")]
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace IronNotify.Client;
|
||||
|
||||
/// <summary>
|
||||
/// File-based offline queue for notification events.
|
||||
/// Persists failed sends to disk and retries with exponential backoff.
|
||||
/// </summary>
|
||||
public class NotifyOfflineQueue : IDisposable
|
||||
{
|
||||
private readonly string _queueFilePath;
|
||||
private readonly int _maxQueueSize;
|
||||
private readonly object _fileLock = new();
|
||||
private readonly SemaphoreSlim _retrySemaphore = new(1, 1);
|
||||
private readonly Timer? _retryTimer;
|
||||
private readonly Func<QueuedEvent, Task<bool>> _sendFunc;
|
||||
private int _retryAttempt;
|
||||
private bool _disposed;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new offline queue.
|
||||
/// </summary>
|
||||
/// <param name="sendFunc">Function to send an event to server. Returns true on success.</param>
|
||||
/// <param name="queueDirectory">Directory to store queue file. Defaults to app data.</param>
|
||||
/// <param name="maxQueueSize">Maximum items to store. Oldest items dropped when exceeded.</param>
|
||||
/// <param name="enableAutoRetry">Whether to automatically retry sending queued items.</param>
|
||||
public NotifyOfflineQueue(
|
||||
Func<QueuedEvent, Task<bool>> sendFunc,
|
||||
string? queueDirectory = null,
|
||||
int maxQueueSize = 500,
|
||||
bool enableAutoRetry = true)
|
||||
{
|
||||
_sendFunc = sendFunc;
|
||||
_maxQueueSize = maxQueueSize;
|
||||
|
||||
var directory = queueDirectory ?? GetDefaultQueueDirectory();
|
||||
Directory.CreateDirectory(directory);
|
||||
_queueFilePath = Path.Combine(directory, "notify_queue.json");
|
||||
|
||||
if (enableAutoRetry)
|
||||
{
|
||||
// Start retry timer - initial delay 30 seconds, then every 60 seconds
|
||||
_retryTimer = new Timer(
|
||||
_ => _ = RetryQueuedItemsAsync(),
|
||||
null,
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromSeconds(60));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Number of items currently in the queue.
|
||||
/// </summary>
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_fileLock)
|
||||
{
|
||||
var items = LoadQueue();
|
||||
return items.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueue an event that failed to send.
|
||||
/// </summary>
|
||||
public void Enqueue(NotifyEventRequest request)
|
||||
{
|
||||
lock (_fileLock)
|
||||
{
|
||||
var queue = LoadQueue();
|
||||
queue.Add(new QueuedEvent
|
||||
{
|
||||
QueuedAt = DateTime.UtcNow,
|
||||
Request = request
|
||||
});
|
||||
|
||||
// Trim to max size (remove oldest)
|
||||
while (queue.Count > _maxQueueSize)
|
||||
{
|
||||
queue.RemoveAt(0);
|
||||
}
|
||||
|
||||
SaveQueue(queue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to send all queued items.
|
||||
/// </summary>
|
||||
public async Task<int> RetryQueuedItemsAsync()
|
||||
{
|
||||
if (!await _retrySemaphore.WaitAsync(0))
|
||||
{
|
||||
// Already retrying
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sentCount = 0;
|
||||
|
||||
try
|
||||
{
|
||||
List<QueuedEvent> items;
|
||||
lock (_fileLock)
|
||||
{
|
||||
items = LoadQueue();
|
||||
}
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
_retryAttempt = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
var remaining = new List<QueuedEvent>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var success = await _sendFunc(item);
|
||||
if (success)
|
||||
{
|
||||
sentCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
remaining.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Save remaining items
|
||||
lock (_fileLock)
|
||||
{
|
||||
SaveQueue(remaining);
|
||||
}
|
||||
|
||||
if (remaining.Count == 0)
|
||||
{
|
||||
_retryAttempt = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
_retryAttempt++;
|
||||
}
|
||||
|
||||
return sentCount;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_retryAttempt++;
|
||||
return 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_retrySemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all queued items without sending.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_fileLock)
|
||||
{
|
||||
SaveQueue(new List<QueuedEvent>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get all queued items (for display/export).
|
||||
/// </summary>
|
||||
public List<QueuedEvent> GetQueuedItems()
|
||||
{
|
||||
lock (_fileLock)
|
||||
{
|
||||
return LoadQueue();
|
||||
}
|
||||
}
|
||||
|
||||
private List<QueuedEvent> LoadQueue()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_queueFilePath))
|
||||
{
|
||||
return new List<QueuedEvent>();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_queueFilePath);
|
||||
return JsonSerializer.Deserialize<List<QueuedEvent>>(json, JsonOptions)
|
||||
?? new List<QueuedEvent>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<QueuedEvent>();
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveQueue(List<QueuedEvent> items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(items, JsonOptions);
|
||||
File.WriteAllText(_queueFilePath, json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore write errors
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDefaultQueueDirectory()
|
||||
{
|
||||
return Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"IronNotify",
|
||||
"Queue");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_retryTimer?.Dispose();
|
||||
_retrySemaphore.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A queued notification event with metadata.
|
||||
/// </summary>
|
||||
public class QueuedEvent
|
||||
{
|
||||
public DateTime QueuedAt { get; set; }
|
||||
public int RetryCount { get; set; }
|
||||
public NotifyEventRequest Request { get; set; } = new();
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
# IronNotify.Client
|
||||
|
||||
Event notification SDK for .NET applications. Send alerts via push, email, SMS, webhooks, and in-app notifications with offline queue support.
|
||||
|
||||
[](https://www.nuget.org/packages/IronNotify.Client)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
dotnet add package IronNotify.Client
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Simple Usage
|
||||
|
||||
```csharp
|
||||
using IronNotify.Client;
|
||||
|
||||
// Create client with API key
|
||||
var client = new NotifyClient("your-api-key", appSlug: "my-app");
|
||||
|
||||
// Send an event
|
||||
var result = await client.NotifyAsync(
|
||||
eventType: "order.completed",
|
||||
title: "New Order Received",
|
||||
severity: Severity.Info,
|
||||
message: "Order #12345 has been placed"
|
||||
);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
Console.WriteLine($"Event sent! ID: {result.EventId}");
|
||||
}
|
||||
```
|
||||
|
||||
### Fluent Builder
|
||||
|
||||
```csharp
|
||||
var result = await client
|
||||
.Event("payment.failed")
|
||||
.WithSeverity(Severity.High)
|
||||
.WithTitle("Payment Failed")
|
||||
.WithMessage("Customer payment was declined")
|
||||
.WithEntityId("order-12345")
|
||||
.WithMetadata("amount", 99.99)
|
||||
.WithMetadata("currency", "USD")
|
||||
.WithAction("retry", "Retry Payment", webhookUrl: "https://api.example.com/retry")
|
||||
.SendAsync();
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```csharp
|
||||
var client = new NotifyClient(new NotifyClientOptions
|
||||
{
|
||||
// Required
|
||||
ApiKey = "your-api-key",
|
||||
|
||||
// Optional
|
||||
DefaultAppSlug = "my-app",
|
||||
DefaultSource = "backend-service",
|
||||
BaseUrl = "https://ironnotify.com",
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
|
||||
// Offline queue (enabled by default)
|
||||
EnableOfflineQueue = true,
|
||||
MaxOfflineQueueSize = 500,
|
||||
OfflineQueueDirectory = null // Uses LocalApplicationData by default
|
||||
});
|
||||
```
|
||||
|
||||
## Severity Levels
|
||||
|
||||
```csharp
|
||||
Severity.Info // Informational events
|
||||
Severity.Warning // Warnings that may need attention
|
||||
Severity.High // Important events requiring action
|
||||
Severity.Critical // Critical events requiring immediate attention
|
||||
```
|
||||
|
||||
## Metadata
|
||||
|
||||
Add custom key-value pairs to events:
|
||||
|
||||
```csharp
|
||||
await client.NotifyAsync(new NotifyEventRequest
|
||||
{
|
||||
EventType = "user.signup",
|
||||
Title = "New User Registration",
|
||||
Severity = Severity.Info,
|
||||
Metadata = new Dictionary<string, object>
|
||||
{
|
||||
["userId"] = "user-123",
|
||||
["plan"] = "pro",
|
||||
["referrer"] = "google"
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Actions
|
||||
|
||||
Add clickable actions to notifications:
|
||||
|
||||
```csharp
|
||||
await client
|
||||
.Event("server.down")
|
||||
.WithSeverity(Severity.Critical)
|
||||
.WithTitle("Server Unreachable")
|
||||
.WithAction("restart", "Restart Server", webhookUrl: "https://api.example.com/restart")
|
||||
.WithAction("acknowledge", "Acknowledge")
|
||||
.SendAsync();
|
||||
```
|
||||
|
||||
## Offline Queue
|
||||
|
||||
Events are automatically queued when the network is unavailable:
|
||||
|
||||
```csharp
|
||||
var result = await client.NotifyAsync("event.type", "Title");
|
||||
|
||||
if (result.Queued)
|
||||
{
|
||||
Console.WriteLine("Event queued for retry when online");
|
||||
}
|
||||
|
||||
// Check queue status
|
||||
if (client.OfflineQueue != null)
|
||||
{
|
||||
Console.WriteLine($"Queued items: {client.OfflineQueue.Count}");
|
||||
}
|
||||
```
|
||||
|
||||
The offline queue:
|
||||
- Persists events to disk
|
||||
- Automatically retries when connectivity is restored
|
||||
- Respects `MaxOfflineQueueSize` limit
|
||||
- Works across app restarts
|
||||
|
||||
## Real-Time Notifications
|
||||
|
||||
For receiving real-time notifications, use `NotifyRealTimeClient`:
|
||||
|
||||
```csharp
|
||||
using IronNotify.Client;
|
||||
|
||||
var realtime = new NotifyRealTimeClient(
|
||||
hubUrl: "https://ironnotify.com/hubs/events",
|
||||
apiKey: "your-api-key"
|
||||
);
|
||||
|
||||
// Subscribe to events
|
||||
realtime.OnEventReceived += (sender, evt) =>
|
||||
{
|
||||
Console.WriteLine($"Received: {evt.Title} ({evt.Severity})");
|
||||
};
|
||||
|
||||
// Connect
|
||||
await realtime.ConnectAsync();
|
||||
|
||||
// Join app channel
|
||||
await realtime.JoinAppAsync("my-app");
|
||||
|
||||
// Disconnect when done
|
||||
await realtime.DisconnectAsync();
|
||||
```
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
```csharp
|
||||
// Register in DI container
|
||||
services.AddSingleton<NotifyClient>(sp =>
|
||||
new NotifyClient(new NotifyClientOptions
|
||||
{
|
||||
ApiKey = configuration["IronNotify:ApiKey"],
|
||||
DefaultAppSlug = configuration["IronNotify:AppSlug"]
|
||||
}));
|
||||
```
|
||||
|
||||
## Common Event Types
|
||||
|
||||
```csharp
|
||||
// User events
|
||||
"user.signup", "user.login", "user.password_reset"
|
||||
|
||||
// Order events
|
||||
"order.created", "order.completed", "order.cancelled"
|
||||
|
||||
// Payment events
|
||||
"payment.succeeded", "payment.failed", "payment.refunded"
|
||||
|
||||
// System events
|
||||
"server.error", "deployment.completed", "backup.finished"
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- [Documentation](https://www.ironnotify.com/docs)
|
||||
- [Dashboard](https://www.ironnotify.com)
|
||||
- [API Reference](https://www.ironnotify.com/docs/api)
|
||||
- [Support](https://www.ironnotify.com/app/tickets)
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
Loading…
Reference in New Issue