Files
omni-notify/Services.cs
2026-05-20 12:53:24 +08:00

272 lines
7.8 KiB
C#

using System.IO;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Windows;
namespace OmniNotify;
public sealed class AppStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private readonly string _statePath;
public AppStore()
{
var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OmniNotify");
Directory.CreateDirectory(root);
_statePath = Path.Combine(root, "state.json");
}
public AppState Load()
{
if (!File.Exists(_statePath))
{
var state = new AppState();
state.Channels.Add(new Channel { Name = "default" });
return state;
}
try
{
return JsonSerializer.Deserialize<AppState>(File.ReadAllText(_statePath), JsonOptions) ?? new AppState();
}
catch
{
return new AppState();
}
}
public void Save(AppState state)
{
CleanupHistory(state);
File.WriteAllText(_statePath, JsonSerializer.Serialize(state, JsonOptions));
}
private static void CleanupHistory(AppState state)
{
var cutoff = DateTime.Now.AddDays(-Math.Max(1, state.Settings.RetainDays));
var survivors = state.History.Where(item => item.ReceivedAt >= cutoff)
.OrderByDescending(item => item.ReceivedAt)
.Take(Math.Max(1, state.Settings.RetainCount))
.OrderBy(item => item.ReceivedAt)
.ToList();
state.History.Clear();
foreach (var item in survivors)
{
state.History.Add(item);
}
}
}
public sealed class NotificationRouter
{
private readonly AppState _state;
private readonly AppStore _store;
private readonly PopupCoordinator _popupCoordinator;
private readonly Queue<DateTime> _rateWindow = new();
public event Action? StateChanged;
public NotificationRouter(AppState state, AppStore store, PopupCoordinator popupCoordinator)
{
_state = state;
_store = store;
_popupCoordinator = popupCoordinator;
}
public void Receive(IncomingMessage message, bool recordHistory = true)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
if (_state.Settings.CircuitBreakerOpen)
{
return;
}
if (IsRateLimited())
{
_state.Settings.CircuitBreakerOpen = true;
_popupCoordinator.ShowSystem("熔断保护", "检测到大量消息,已开启熔断保护。");
SaveAndNotify();
return;
}
var channel = _state.Channels.FirstOrDefault(item =>
string.Equals(item.Name, message.Channel, StringComparison.Ordinal));
if (channel is null)
{
AddHistory(message, NotificationStatus.IllegalChannel);
SaveAndNotify();
return;
}
if (_state.Settings.DndEnabled)
{
AddHistory(message, NotificationStatus.DndMuted);
SaveAndNotify();
return;
}
if (recordHistory)
{
AddHistory(message, NotificationStatus.Displayed);
}
_popupCoordinator.Show(channel, message);
SaveAndNotify();
});
}
public void Replay(HistoryItem item)
{
var channel = _state.Channels.FirstOrDefault(candidate =>
string.Equals(candidate.Name, item.Channel, StringComparison.Ordinal));
if (channel is not null)
{
_popupCoordinator.Show(channel, new IncomingMessage { Channel = item.Channel, Title = item.Title, Body = item.Body });
}
}
public void ResetCircuitBreaker()
{
_state.Settings.CircuitBreakerOpen = false;
_rateWindow.Clear();
SaveAndNotify();
}
private bool IsRateLimited()
{
var now = DateTime.Now;
while (_rateWindow.Count > 0 && (now - _rateWindow.Peek()).TotalSeconds > 1)
{
_rateWindow.Dequeue();
}
_rateWindow.Enqueue(now);
return _rateWindow.Count > Math.Max(1, _state.Settings.MaxMessagesPerSecond);
}
private void AddHistory(IncomingMessage message, NotificationStatus status)
{
_state.History.Add(new HistoryItem
{
Channel = message.Channel,
Title = message.Title,
Body = message.Body,
Status = status
});
}
private void SaveAndNotify()
{
_store.Save(_state);
StateChanged?.Invoke();
}
}
public sealed class LocalHttpServer : IDisposable
{
private readonly NotificationRouter _router;
private HttpListener? _listener;
private CancellationTokenSource? _cts;
public LocalHttpServer(NotificationRouter router)
{
_router = router;
}
public string? Url { get; private set; }
public string? LastError { get; private set; }
public void Start(int port)
{
Url = $"http://127.0.0.1:{port}/";
_cts = new CancellationTokenSource();
_listener = new HttpListener();
_listener.Prefixes.Add(Url);
try
{
_listener.Start();
_ = ListenAsync(_cts.Token);
}
catch (Exception ex)
{
LastError = ex.Message;
}
}
private async Task ListenAsync(CancellationToken token)
{
while (!token.IsCancellationRequested && _listener?.IsListening == true)
{
try
{
var context = await _listener.GetContextAsync();
_ = Task.Run(() => HandleAsync(context), token);
}
catch when (token.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
LastError = ex.Message;
}
}
}
private async Task HandleAsync(HttpListenerContext context)
{
try
{
if (context.Request.HttpMethod == "GET")
{
await WriteAsync(context, 200, "Omni-Notify is listening. POST /notify with JSON: {\"channel\":\"default\",\"title\":\"Hi\",\"body\":\"Text\"}");
return;
}
if (context.Request.HttpMethod != "POST" || context.Request.Url?.AbsolutePath != "/notify")
{
await WriteAsync(context, 404, "Not found");
return;
}
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
var body = await reader.ReadToEndAsync();
var message = JsonSerializer.Deserialize<IncomingMessage>(body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (message is null || string.IsNullOrWhiteSpace(message.Channel))
{
await WriteAsync(context, 400, "Invalid message");
return;
}
_router.Receive(message);
await WriteAsync(context, 202, "Accepted");
}
catch (Exception ex)
{
await WriteAsync(context, 500, ex.Message);
}
}
private static async Task WriteAsync(HttpListenerContext context, int code, string text)
{
var bytes = Encoding.UTF8.GetBytes(text);
context.Response.StatusCode = code;
context.Response.ContentType = "text/plain; charset=utf-8";
context.Response.ContentLength64 = bytes.Length;
await context.Response.OutputStream.WriteAsync(bytes);
context.Response.Close();
}
public void Dispose()
{
_cts?.Cancel();
_listener?.Close();
}
}