aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.mdbin681 -> 2764 bytes
-rw-r--r--Dockerfile11
-rw-r--r--Handlers/DiscordHandler.cs626
-rw-r--r--Handlers/HomeAssistantHandler.cs54
-rw-r--r--Migrations/20230806022522_MapType.Designer.cs162
-rw-r--r--Migrations/20230806022522_MapType.cs51
-rw-r--r--Migrations/20230806155332_PagedServices.Designer.cs167
-rw-r--r--Migrations/20230806155332_PagedServices.cs30
-rw-r--r--Migrations/20230807001127_IndexPageDestination.Designer.cs169
-rw-r--r--Migrations/20230807001127_IndexPageDestination.cs27
-rw-r--r--Migrations/20241011050136_v0.4-rc1.Designer.cs222
-rw-r--r--Migrations/20241011050136_v0.4-rc1.cs67
-rw-r--r--Migrations/PagerContextModelSnapshot.cs69
-rw-r--r--PagerContext.cs2
-rw-r--r--PagerFetchService.cs111
-rw-r--r--PagerHandler.cs125
-rw-r--r--PagerMessage.cs71
-rw-r--r--PagerParser.csproj33
-rw-r--r--Pages/JobCounter/Index.cshtml17
-rw-r--r--Pages/JobCounter/Index.cshtml.cs74
-rw-r--r--Pages/JobCounter/Index.cshtml.css14
-rw-r--r--Pages/Shared/_Layout.cshtml12
-rw-r--r--Pages/_ViewImports.cshtml3
-rw-r--r--Pages/_ViewStart.cshtml3
-rw-r--r--PositionCalculator.cs29
-rw-r--r--Program.cs32
-rw-r--r--README.md24
-rw-r--r--Sites/PagerMon.cs16
-rw-r--r--appsettings.Development.json3
-rw-r--r--appsettings.json37
-rw-r--r--compose.yaml26
-rw-r--r--wwwroot/styles/global.css4
32 files changed, 2213 insertions, 78 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ef1e09..dc92823 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
Binary files differ
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..b89f92f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,11 @@
+FROM mcr.microsoft.com/dotnet/sdk:8.0@sha256:35792ea4ad1db051981f62b313f1be3b46b1f45cadbaa3c288cd0d3056eefb83 AS build-env
+WORKDIR /App
+
+COPY . ./
+RUN dotnet restore
+RUN dotnet publish -c Release -o out
+
+FROM mcr.microsoft.com/dotnet/aspnet:8.0@sha256:6c4df091e4e531bb93bdbfe7e7f0998e7ced344f54426b7e874116a3dc3233ff
+WORKDIR /App
+COPY --from=build-env /App/out .
+ENTRYPOINT [ "dotnet", "PagerParser.dll" ]
diff --git a/Handlers/DiscordHandler.cs b/Handlers/DiscordHandler.cs
new file mode 100644
index 0000000..ae948c5
--- /dev/null
+++ b/Handlers/DiscordHandler.cs
@@ -0,0 +1,626 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Text.RegularExpressions;
+using Discord;
+using Discord.WebSocket;
+using Microsoft.EntityFrameworkCore;
+using PagerParser.Handlers;
+
+namespace PagerParser {
+ public partial class PagerContext {
+ public DbSet<DiscordAlertChannel> DiscordAlertChannels { get; set; }
+ public DbSet<DiscordAlertSubscription> DiscordAlertSubscriptions { get; set; }
+ }
+}
+
+namespace PagerParser.Handlers {
+ [PagerHandler]
+ public class DiscordHandler : IPagerHandler {
+ // TODO: consolidate this with the map used by TTS
+ private readonly Dictionary<string, string> IncidentTypes = new() {
+ { "ALAR", "FIRE ALARM" },
+ { "G&S", "GRASS & SCRUB" },
+ { "HIAR", "HIGH-ANGLE RESCUE" },
+ { "INCI", "INCIDENT" },
+ { "NOST", "NON-STRUCTURE" },
+ { "RESC", "RESCUE" },
+ { "STRU", "STRUCTURE" }
+ };
+
+ private readonly CommandBuilder[] CommandBuilders;
+
+ private DiscordSocketClient discordClient;
+
+ private Dictionary<int, DateTime> recentMessages = new();
+
+ private string botToken;
+
+ private ILogger logger;
+ private IServiceProvider serviceProvider;
+
+ public DiscordHandler() {
+ // Initialize the command builders here so that instance
+ // methods may be used for command handling.
+ CommandBuilders = [
+ new CommandBuilder() {
+ Name = "get-channel",
+ Description = "Get the channel to which alerts will be sent",
+ ContextTypes = [ InteractionContextType.Guild ],
+ CommandHandler = OnGetChannelCommand,
+ },
+ new CommandBuilder() {
+ Name = "set-channel",
+ Description = "Set the channel to which alerts will be sent",
+ ContextTypes = [ InteractionContextType.Guild ],
+ CommandHandler = OnSetChannelCommand,
+ Options = [
+ new() {
+ Name = "channel",
+ Description = "Channel to which alerts will be sent",
+ Type = ApplicationCommandOptionType.Channel,
+ IsRequired = true
+ },
+ new() {
+ Name = "all-messages",
+ Description = "Send messages even if nobody is subcribed (defaults to 'False')",
+ Type = ApplicationCommandOptionType.Boolean,
+ }
+ ]
+ },
+ new CommandBuilder() {
+ Name = "remove-channel",
+ Description = "Reset the configured alert channel, effectively disabling alerts for this server",
+ CommandHandler = OnRemoveChannelCommand
+ },
+ new CommandBuilder() {
+ Name = "subscribe",
+ Description = "Subscribe a user/role to alerts for the specified brigade/appliance",
+ ContextTypes = [ InteractionContextType.Guild ],
+ CommandHandler = OnSubscribeChannelCommand,
+ Options = [
+ new() {
+ Name = "mention",
+ Description = "User/role to subscribe",
+ Type = ApplicationCommandOptionType.Mentionable,
+ IsRequired = true
+ },
+ new() {
+ Name = "brigade",
+ Description = "Paged brigade/appliance to match (e.g. CBERW, P88, etc)",
+ Type = ApplicationCommandOptionType.String,
+ IsRequired = true
+ }
+ ]
+ },
+ new CommandBuilder() {
+ Name = "unsubscribe",
+ Description = "Unsubscribe a user/role from alerts for the specified brigade/appliance",
+ ContextTypes = [ InteractionContextType.Guild ],
+ CommandHandler = OnUnsubscribeChannelCommand,
+ Options = [
+ new() {
+ Name = "mention",
+ Description = "User/role to unsubscribe",
+ Type = ApplicationCommandOptionType.Mentionable,
+ IsRequired = true
+ },
+ new() {
+ Name = "brigade",
+ Description = "Paged brigade/appliance to match (e.g. CBERW, P88, etc)",
+ Type = ApplicationCommandOptionType.String,
+ IsRequired = true
+ }
+ ]
+ },
+ new CommandBuilder() {
+ Name = "list-subscriptions",
+ Description = "List the brigades/appliances whose pager messages the user/role is subscribed to",
+ ContextTypes = [ InteractionContextType.Guild ],
+ CommandHandler = OnListSubscriptionsCommand,
+ Options = [
+ new() {
+ Name = "mention",
+ Description = "User/role whose subscriptions will be listed (defaults to the current user)",
+ Type = ApplicationCommandOptionType.Mentionable
+ }
+ ]
+ }
+ ];
+ }
+
+ public async Task HandleMessageAsync(PagerMessage message, ParsedPagerMessage? pm) {
+ if(pm is null)
+ return;
+
+ // Calculate a hash to eliminate duplicate messages
+ var hash = (
+ pm.AssignmentArea,
+ pm.JobType,
+ pm.AlertLevel,
+ pm.Description,
+ pm.FireGroundChannel,
+ pm.FirecomJobNo).GetHashCode();
+
+ // Prune the hash list
+ recentMessages
+ .Where(kv => DateTime.Now - kv.Value > TimeSpan.FromMinutes(2))
+ .Select(kv => kv.Key)
+ .ToList()
+ .ForEach(h => recentMessages.Remove(h));
+
+ // Ignore this message if we've seen a similar one recently
+ if(recentMessages.Keys.Contains(hash))
+ return;
+
+ // Record the hash of this message for future deduplication
+ recentMessages[hash] = DateTime.Now;
+
+ // Get a simplified list of paged services
+ var pagedServices = pm.PagedServices
+ .Select(ps => Regex.Replace(ps, "^C([A-Z]{4})$", "$1"))
+ .Select(ps => Regex.Replace(ps, "^[A-Z]*([0-9]{2})[A-Z]*", "FS$1"))
+ .Order()
+ .Distinct();
+
+ // Translate the job type to a more friendly string
+ string mapType = PagerMessageParserService.MapTypeMap
+ .FirstOrDefault(kv => kv.Value == pm.MapType).Key;
+ IncidentTypes.TryGetValue(pm.JobType, out var incidentType);
+
+ // Compose a base message to be posted to each configured webhook
+ string[] messageComponents = [
+ Color(AnsiColor.Red, "ALERT"),
+ Color(pm.AlertLevel == AlertLevel.Code1 ? AnsiColor.Red : AnsiColor.White, $"CODE {(int) pm.AlertLevel}"),
+ pm.AssignmentArea,
+ Color(AnsiColor.Cyan, incidentType ?? pm.JobType),
+ pm.Description,
+ Color(AnsiColor.Blue, $"{mapType} {pm.MapNo} {pm.MapGrid} ({pm.GridReference})"),
+ Color(AnsiColor.Cyan, pm.Note ?? ""),
+ pm.FireGroundChannel is null ? "" : Color(AnsiColor.Yellow, $"FGD{pm.FireGroundChannel}"),
+ Color(AnsiColor.Magenta, $"F{pm.FirecomJobNo}")
+ ];
+
+ string baseMessage =
+ $"```ansi\n{string.Join(' ', messageComponents.Where(x => !string.IsNullOrEmpty(x)))}\n```";
+
+ using var scope = serviceProvider.CreateScope();
+ using var db = scope.ServiceProvider.GetRequiredService<PagerContext>();
+
+ foreach(var guild in discordClient.Guilds) {
+ // Check that a notification channel is assigned for this guild,
+ // otherwise, skip it.
+ var alertChannel = db.DiscordAlertChannels
+ .Where(ac => ac.GuildId == guild.Id)
+ .FirstOrDefault();
+ if(alertChannel is null)
+ continue;
+
+ // Get a list of alert groups matching the page we've just received
+ var subscriptions = db.DiscordAlertSubscriptions
+ .Where(s => pagedServices.Contains(s.PageDestination))
+ .ToArray();
+
+ // Don't post the message to the server if nobody has subscribed
+ // and this alert channel is configured for alerts to only be
+ // posted when they mention a specific user or role.
+ if(subscriptions.Count() == 0 && alertChannel.RequireMention)
+ continue;
+
+ // Figure out which roles and users we need to @ mention
+ // and generate appropriate Discord mention strings
+ var roleMentions = subscriptions
+ .Where(s => s.PrincipalType == DiscordPrincipalType.Role)
+ .Distinct()
+ .Select(s => MentionUtils.MentionRole(s.PrincipalId));
+
+ var userMentions = subscriptions
+ .Where(s => s.PrincipalType == DiscordPrincipalType.User)
+ .Distinct()
+ .Select(s => MentionUtils.MentionUser(s.PrincipalId));
+
+ var mentions = string.Join(' ', roleMentions.Concat(userMentions));
+
+ // Generate a list of paged services to be highlighted.
+ // Services are highlighted if they exist in one of the
+ // pre-configured alert groups and have therefore caused
+ // a role to be mentioned.
+ var hl = subscriptions
+ .Select(s => s.PageDestination)
+ .Distinct()
+ .ToArray();
+
+ // Cleanup the list of paged services and generate
+ // text to be displayed
+ var pagedServicesText = string.Join(' ', pagedServices
+ .Select(ps => hl.Contains(ps) ? $"***{ps}***" : $"*{ps}*"));
+
+ // Generate a message tailored to the
+ // specific webhook we're posting to
+ string discordMessage =
+ $"{(string.IsNullOrEmpty(mentions) ? "" : mentions + '\n')}" +
+ baseMessage +
+ $"\nPaged Services: {pagedServicesText}";
+
+ // Actually post the message
+ try {
+ await guild
+ .GetTextChannel(alertChannel.ChannelId)
+ .SendMessageAsync(discordMessage);
+ } catch(Exception e) {
+ logger.LogError(
+ e,
+ "Error sending Discord message");
+ }
+ }
+ }
+
+ private async Task OnGetChannelCommand(SocketSlashCommand command) {
+ using var scope = serviceProvider.CreateScope();
+ using var db = scope.ServiceProvider.GetRequiredService<PagerContext>();
+
+ var alertChannel = db.DiscordAlertChannels
+ .FirstOrDefault(c => c.GuildId == command.GuildId);
+
+ if(alertChannel is null) {
+ await command.RespondAsync(
+ "No alert channel has been configured for this server!");
+ return;
+ }
+
+ await command.RespondAsync(
+ string.Join(' ', [
+ $"The configured alert channel for this server is",
+ $"<#{alertChannel.ChannelId}>",
+ alertChannel.RequireMention ? null : "(all messages will be sent)"
+ ]));
+ }
+
+ private async Task OnSetChannelCommand(SocketSlashCommand command) {
+ logger.LogDebug("Executing the 'set-channel' command...");
+
+ using var scope = serviceProvider.CreateScope();
+ using var db = scope.ServiceProvider.GetRequiredService<PagerContext>();
+
+ await db.Database.BeginTransactionAsync();
+
+ var alertChannel = await db.DiscordAlertChannels
+ .FirstOrDefaultAsync(c => c.GuildId == command.GuildId);
+
+ // Ensure the selected channel is actually a
+ // text channel and not a channel category.
+ if(command.Data.Options.First().Value is not SocketTextChannel) {
+ await command.RespondAsync(
+ "Error: Pager message notifications can only be posted to text channels");
+ return;
+ }
+
+ var channel = (SocketTextChannel) command.Data.Options.First().Value;
+ var requireMention = !command.Data.Options
+ .Where(o => o.Type == ApplicationCommandOptionType.Boolean)
+ .Select(o => (bool?) o.Value)
+ .FirstOrDefault() ?? true;
+
+ if(alertChannel is null) {
+ alertChannel = new() {
+ GuildId = (ulong) command.GuildId!,
+ ChannelId = channel.Id,
+ RequireMention = requireMention
+ };
+ await db.DiscordAlertChannels.AddAsync(alertChannel);
+ } else {
+ alertChannel.ChannelId = channel.Id;
+ alertChannel.RequireMention = requireMention;
+ }
+
+ await db.SaveChangesAsync();
+ await db.Database.CommitTransactionAsync();
+
+ await command.RespondAsync(
+ string.Join(' ', [
+ "The alert channel for this server has been set to",
+ $"<#{channel.Id}>",
+ requireMention ? null : "(all messages will be sent)"
+ ]));
+ }
+
+ private async Task OnRemoveChannelCommand(SocketSlashCommand command) {
+ using var scope = serviceProvider.CreateScope();
+ using var db = scope.ServiceProvider.GetRequiredService<PagerContext>();
+
+ await db.Database.BeginTransactionAsync();
+
+ var alertChannel = await db.DiscordAlertChannels
+ .FirstOrDefaultAsync(c => c.GuildId == command.GuildId);
+
+ if(alertChannel is null) {
+ await command.RespondAsync("No alert channel was configured for this server!");
+ return;
+ }
+
+ db.Remove(alertChannel);
+
+ await db.SaveChangesAsync();
+ await db.Database.CommitTransactionAsync();
+
+ await command.RespondAsync(
+ $"Removed the configured alert channel for this server");
+ }
+
+ private async Task OnSubscribeChannelCommand(SocketSlashCommand command) {
+ using var scope = serviceProvider.CreateScope();
+ using var db = scope.ServiceProvider.GetRequiredService<PagerContext>();
+
+ string mention = string.Empty;
+
+ var subscription = new DiscordAlertSubscription() {
+ GuildId = (ulong) command.GuildId!,
+ PageDestination = (string) command.Data.Options.Last().Value
+ };
+
+ switch(command.Data.Options.First().Value) {
+ case SocketUser user:
+ subscription.PrincipalType = DiscordPrincipalType.User;
+ subscription.PrincipalId = user.Id;
+ mention = $"<@!{user.Id}>";
+ break;
+ case SocketRole role:
+ subscription.PrincipalType = DiscordPrincipalType.Role;
+ subscription.PrincipalId = role.Id;
+ mention = $"<@&{role.Id}>";
+ break;
+ }
+
+ await db.Database.BeginTransactionAsync();
+
+ var exists = db.DiscordAlertSubscriptions
+ .Where(s => s.GuildId == command.GuildId)
+ .Where(s => s.PrincipalType == subscription.PrincipalType)
+ .Where(s => s.PrincipalId == subscription.PrincipalId)
+ .Where(s => s.PageDestination == subscription.PageDestination)
+ .Any();
+
+ if(exists) {
+ await command.RespondAsync(string.Join(' ', [
+ mention,
+ subscription.PrincipalType == DiscordPrincipalType.User ? "is" : "are",
+ "already subscribed to pager messages for",
+ $"`{subscription.PageDestination}`"
+ ]));
+ return;
+ }
+
+ db.DiscordAlertSubscriptions.Add(subscription);
+ await db.SaveChangesAsync();
+ await db.Database.CommitTransactionAsync();
+
+ await command.RespondAsync(string.Join(' ', [
+ "Subscribed",
+ $"<@{subscription.PrincipalId}>",
+ "to pager messages for",
+ $"`{subscription.PageDestination}`"
+ ]));
+ }
+
+ private async Task OnUnsubscribeChannelCommand(SocketSlashCommand command) {
+ using var scope = serviceProvider.CreateScope();
+ using var db = scope.ServiceProvider.GetRequiredService<PagerContext>();
+
+ string mention = string.Empty;
+
+ var subscription = new DiscordAlertSubscription() {
+ GuildId = (ulong) command.GuildId!,
+ PageDestination = (string) command.Data.Options.Last().Value
+ };
+
+ switch(command.Data.Options.First().Value) {
+ case SocketUser user:
+ subscription.PrincipalType = DiscordPrincipalType.User;
+ subscription.PrincipalId = user.Id;
+ mention = $"<@!{user.Id}>";
+ break;
+ case SocketRole role:
+ subscription.PrincipalType = DiscordPrincipalType.Role;
+ subscription.PrincipalId = role.Id;
+ mention = $"<@&{role.Id}>";
+ break;
+ }
+
+ await db.Database.BeginTransactionAsync();
+
+ var existing = db.DiscordAlertSubscriptions
+ .Where(s => s.GuildId == command.GuildId)
+ .Where(s => s.PrincipalType == subscription.PrincipalType)
+ .Where(s => s.PrincipalId == subscription.PrincipalId)
+ .Where(s => s.PageDestination == subscription.PageDestination);
+
+ if(!existing.Any()) {
+ await command.RespondAsync(string.Join(' ', [
+ mention,
+ subscription.PrincipalType == DiscordPrincipalType.User ? "was" : "are",
+ "not subscribed to pager messages for",
+ $"`{subscription.PageDestination}`"
+ ]));
+ return;
+ }
+
+ db.DiscordAlertSubscriptions.RemoveRange(existing);
+ await db.SaveChangesAsync();
+ await db.Database.CommitTransactionAsync();
+
+ await command.RespondAsync(string.Join(' ', [
+ "Unsubscribed",
+ $"<@{subscription.PrincipalId}>",
+ "from pager messages for",
+ $"`{subscription.PageDestination}`"
+ ]));
+ }
+
+ private async Task OnListSubscriptionsCommand(SocketSlashCommand command) {
+ using var scope = serviceProvider.CreateScope();
+ using var db = scope.ServiceProvider.GetRequiredService<PagerContext>();
+
+ DiscordPrincipalType principalType;
+ ulong principalId;
+ string mention;
+
+ switch(command.Data.Options.FirstOrDefault()?.Value) {
+ case SocketUser user:
+ principalType = DiscordPrincipalType.User;
+ principalId = user.Id;
+ mention = $"<@!{user.Id}> is";
+ break;
+ case SocketRole role:
+ principalType = DiscordPrincipalType.Role;
+ principalId = role.Id;
+ mention = $"<@&{role.Id}> are";
+ break;
+ default:
+ principalType = DiscordPrincipalType.User;
+ principalId = command.User.Id;
+ mention = $"<@!{command.User.Id}> is";
+ break;
+ }
+
+ var subscriptions = db.DiscordAlertSubscriptions
+ .Where(s => s.GuildId == command.GuildId)
+ .Where(s => s.PrincipalType == principalType)
+ .Where(s => s.PrincipalId == principalId)
+ .OrderBy(s => s.PageDestination)
+ .Select(s => s.PageDestination)
+ .Distinct()
+ .ToArray();
+
+ if(subscriptions.Count() == 0) {
+ await command.RespondAsync(
+ $"{mention} not subscribed to any pager messages");
+ return;
+ }
+
+ await command.RespondAsync(
+ string.Join(
+ "\n - ",
+ Enumerable.Concat(
+ [ $"{mention} subscribed to pager messages for the following brigades/appliances:" ],
+ subscriptions.Select(s => $"`{s}`"))));
+ }
+
+ private async Task OnSlashCommand(SocketSlashCommand command) {
+ logger.LogDebug($"Slash command executed: {command.Data.Name}");
+
+ try {
+ // Search the list of command builders, find the command
+ // that was executed and attempt to invoke it's handler.
+ var builder = CommandBuilders
+ .First(cb => cb.Name == command.Data.Name);
+ await builder.CommandHandler(command);
+ } catch(Exception e) {
+ // Handle any exceptions by writing a detailed error description
+ // to the log and responding to the user with an message and identifier
+ // that can be used to identify the corresponding log entry.
+ var reference = string.Format("{0:X8}", Random.Shared.Next());
+ logger.LogError(e, string.Join(' ', [
+ $"Error executing Discord slash command:",
+ command.Data.Name,
+ "(error reference:",
+ string.Format("{0:X8}", reference),
+ ")"
+ ]));
+ try {
+ await command.RespondAsync(
+ string.Join(' ', [
+ "An unknown error occurred whilst attempting to execute the command.",
+ "Error reference:",
+ $"`{reference}`"
+ ]));
+ } catch {}
+ }
+ }
+
+ private async Task OnReady() {
+ // Register slash commands
+ try {
+ await discordClient.BulkOverwriteGlobalApplicationCommandsAsync(
+ CommandBuilders.Select(b => b.Build()).ToArray());
+ } catch(Exception e) {
+ logger.LogError(
+ e,
+ "Error registering Discord slash commands");
+ }
+ }
+
+ public void OnConfiguring(
+ ILogger logger,
+ IConfiguration config,
+ IServiceProvider serviceProvider) {
+
+ this.logger = logger;
+ this.serviceProvider = serviceProvider;
+
+ botToken = config.GetValue<string>("PagerParser:DiscordBot:Token")!;
+
+ if(botToken is null) {
+ logger.LogError("No bot token configured");
+ throw new InvalidOperationException("Bot token is null!");
+ }
+ }
+
+ public async Task StartAsync(CancellationToken ct) {
+ discordClient = new();
+
+ discordClient.Ready += OnReady;
+ discordClient.SlashCommandExecuted += OnSlashCommand;
+
+ await discordClient.LoginAsync(TokenType.Bot, botToken);
+ await discordClient.StartAsync();
+ }
+
+ public async Task StopAsync(CancellationToken ct) {
+ await discordClient.StopAsync();
+ await discordClient.DisposeAsync();
+ }
+
+ private string Color(AnsiColor color, string text) {
+ if(!string.IsNullOrEmpty(text))
+ return $"\x1B[{(int) color}m{text}\x1B[0m";
+ else
+ return "";
+ }
+
+ private enum AnsiColor {
+ Black = 30, Red = 31, Green = 32, Yellow = 33,
+ Blue = 34, Magenta = 35, Cyan = 36, LightGray = 37,
+ DarkGray = 90, LightRed = 91, LightGreen = 92, LightYellow = 93,
+ LightBlue = 94, LightMagenta = 95, LightCyan = 96, White = 97,
+ }
+
+ private class CommandBuilder : SlashCommandBuilder {
+ public Func<SocketSlashCommand, Task> CommandHandler { get; set; }
+ }
+ }
+
+ [Index(nameof(GuildId), IsUnique = true)]
+ public class DiscordAlertChannel {
+ [Key]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int DiscordAlertChannelId { get; set; }
+ public ulong GuildId { get; set; }
+ public ulong ChannelId { get; set; }
+ public bool RequireMention { get; set; }
+ }
+
+ [Index(nameof(GuildId))]
+ public class DiscordAlertSubscription {
+ [Key]
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
+ public int DiscordAlertSubscriptionId { get; set; }
+ public string PageDestination { get; set; }
+ public ulong GuildId { get; set; }
+ public ulong PrincipalId { get; set; }
+ public DiscordPrincipalType PrincipalType { get; set; }
+ }
+
+ public enum DiscordPrincipalType {
+ User,
+ Role
+ }
+}
diff --git a/Handlers/HomeAssistantHandler.cs b/Handlers/HomeAssistantHandler.cs
new file mode 100644
index 0000000..bf914c3
--- /dev/null
+++ b/Handlers/HomeAssistantHandler.cs
@@ -0,0 +1,54 @@
+using System.Net.Http.Headers;
+
+namespace PagerParser.Handlers;
+
+[PagerHandler]
+public class HomeAssistantHandler : IPagerHandler {
+ private List<HomeAssistantConfig> haConfigs = new();
+
+ private ILogger logger;
+
+ private HttpClient httpClient;
+
+ public async Task HandleMessageAsync(PagerMessage message, ParsedPagerMessage? pm) {
+ // Generate an event via the API on each configured Home Assistant server
+ foreach(var ha in haConfigs) {
+ var url = $"https://{ha.Host}/api/events/{ha.EventType}";
+ httpClient.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", ha.ApiKey);
+ await httpClient.PostAsJsonAsync(
+ url,
+ message);
+ }
+ }
+
+ public void OnConfiguring(
+ ILogger logger,
+ IConfiguration config,
+ IServiceProvider serviceProvider) {
+
+ this.logger = logger;
+
+ config.Bind("PagerParser:HomeAssistant:Servers", haConfigs);
+ if(haConfigs is null)
+ return;
+
+ logger.LogInformation($"{haConfigs.Count()} Home Assistant servers configured");
+ }
+
+ public Task StartAsync(CancellationToken ct) {
+ httpClient = new();
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken ct) {
+ httpClient?.Dispose();
+ return Task.CompletedTask;
+ }
+
+ public class HomeAssistantConfig {
+ public string Host { get; set; }
+ public string ApiKey { get; set; }
+ public string EventType { get; set; } = "cfa_pager_message";
+ }
+}
diff --git a/Migrations/20230806022522_MapType.Designer.cs b/Migrations/20230806022522_MapType.Designer.cs
new file mode 100644
index 0000000..bb6bb95
--- /dev/null
+++ b/Migrations/20230806022522_MapType.Designer.cs
@@ -0,0 +1,162 @@
+// <auto-generated />
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PagerParser;
+
+#nullable disable
+
+namespace PagerParser.Migrations
+{
+ [DbContext(typeof(PagerContext))]
+ [Migration("20230806022522_MapType")]
+ partial class MapType
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "7.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("PagerParser.GpsPosition", b =>
+ {
+ b.Property<int>("GpsPositionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GpsPositionId"));
+
+ b.Property<double>("Latitude")
+ .HasColumnType("double precision");
+
+ b.Property<double>("Longitude")
+ .HasColumnType("double precision");
+
+ b.HasKey("GpsPositionId");
+
+ b.ToTable("GpsPositions");
+ });
+
+ modelBuilder.Entity("PagerParser.PagerMessage", b =>
+ {
+ b.Property<int>("PagerMessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PagerMessageId"));
+
+ b.Property<string>("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<DateTime>("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("PagerMessageId");
+
+ b.HasIndex("Message");
+
+ b.ToTable("PagerMessages");
+ });
+
+ modelBuilder.Entity("PagerParser.ParsedPagerMessage", b =>
+ {
+ b.Property<int>("ParsedPagerMessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ParsedPagerMessageId"));
+
+ b.Property<int>("AlertLevel")
+ .HasColumnType("integer");
+
+ b.Property<string>("AssignmentArea")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<int>("AttendingServices")
+ .HasColumnType("integer");
+
+ b.Property<string>("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<int?>("FireGroundChannel")
+ .HasColumnType("integer");
+
+ b.Property<int>("FirecomJobNo")
+ .HasColumnType("integer");
+
+ b.Property<int?>("GpsPositionId")
+ .HasColumnType("integer");
+
+ b.Property<int?>("GridReference")
+ .HasColumnType("integer");
+
+ b.Property<string>("JobType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<string>("MapGrid")
+ .HasColumnType("text");
+
+ b.Property<int?>("MapNo")
+ .HasColumnType("integer");
+
+ b.Property<int?>("MapType")
+ .HasColumnType("integer");
+
+ b.Property<string>("Note")
+ .HasColumnType("text");
+
+ b.Property<string>("PageDestination")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<int>("PagerMessage")
+ .HasColumnType("integer");
+
+ b.HasKey("ParsedPagerMessageId");
+
+ b.HasIndex("FirecomJobNo");
+
+ b.HasIndex("GpsPositionId");
+
+ b.HasIndex("PagerMessage")
+ .IsUnique();
+
+ b.ToTable("ParsedPagerMessages");
+ });
+
+ modelBuilder.Entity("PagerParser.ParsedPagerMessage", b =>
+ {
+ b.HasOne("PagerParser.GpsPosition", "GpsPosition")
+ .WithMany()
+ .HasForeignKey("GpsPositionId");
+
+ b.HasOne("PagerParser.PagerMessage", "OriginalMessage")
+ .WithOne("ParsedMessage")
+ .HasForeignKey("PagerParser.ParsedPagerMessage", "PagerMessage")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("GpsPosition");
+
+ b.Navigation("OriginalMessage");
+ });
+
+ modelBuilder.Entity("PagerParser.PagerMessage", b =>
+ {
+ b.Navigation("ParsedMessage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Migrations/20230806022522_MapType.cs b/Migrations/20230806022522_MapType.cs
new file mode 100644
index 0000000..062af61
--- /dev/null
+++ b/Migrations/20230806022522_MapType.cs
@@ -0,0 +1,51 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PagerParser.Migrations
+{
+ /// <inheritdoc />
+ public partial class MapType : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "MelwaysMapNo",
+ table: "ParsedPagerMessages",
+ newName: "MapType");
+
+ migrationBuilder.RenameColumn(
+ name: "MelwaysGrid",
+ table: "ParsedPagerMessages",
+ newName: "MapGrid");
+
+ migrationBuilder.AddColumn<int>(
+ name: "MapNo",
+ table: "ParsedPagerMessages",
+ type: "integer",
+ nullable: true);
+
+ migrationBuilder.Sql(
+ "UPDATE \"ParsedPagerMessages\" SET \"MapType\" = 1");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "MapNo",
+ table: "ParsedPagerMessages");
+
+ migrationBuilder.RenameColumn(
+ name: "MapType",
+ table: "ParsedPagerMessages",
+ newName: "MelwaysMapNo");
+
+ migrationBuilder.RenameColumn(
+ name: "MapGrid",
+ table: "ParsedPagerMessages",
+ newName: "MelwaysGrid");
+ }
+ }
+}
diff --git a/Migrations/20230806155332_PagedServices.Designer.cs b/Migrations/20230806155332_PagedServices.Designer.cs
new file mode 100644
index 0000000..497d670
--- /dev/null
+++ b/Migrations/20230806155332_PagedServices.Designer.cs
@@ -0,0 +1,167 @@
+// <auto-generated />
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PagerParser;
+
+#nullable disable
+
+namespace PagerParser.Migrations
+{
+ [DbContext(typeof(PagerContext))]
+ [Migration("20230806155332_PagedServices")]
+ partial class PagedServices
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "7.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("PagerParser.GpsPosition", b =>
+ {
+ b.Property<int>("GpsPositionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GpsPositionId"));
+
+ b.Property<double>("Latitude")
+ .HasColumnType("double precision");
+
+ b.Property<double>("Longitude")
+ .HasColumnType("double precision");
+
+ b.HasKey("GpsPositionId");
+
+ b.ToTable("GpsPositions");
+ });
+
+ modelBuilder.Entity("PagerParser.PagerMessage", b =>
+ {
+ b.Property<int>("PagerMessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PagerMessageId"));
+
+ b.Property<string>("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<DateTime>("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("PagerMessageId");
+
+ b.HasIndex("Message");
+
+ b.ToTable("PagerMessages");
+ });
+
+ modelBuilder.Entity("PagerParser.ParsedPagerMessage", b =>
+ {
+ b.Property<int>("ParsedPagerMessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ParsedPagerMessageId"));
+
+ b.Property<int>("AlertLevel")
+ .HasColumnType("integer");
+
+ b.Property<string>("AssignmentArea")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<int>("AttendingServices")
+ .HasColumnType("integer");
+
+ b.Property<string>("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<int?>("FireGroundChannel")
+ .HasColumnType("integer");
+
+ b.Property<int>("FirecomJobNo")
+ .HasColumnType("integer");
+
+ b.Property<int?>("GpsPositionId")
+ .HasColumnType("integer");
+
+ b.Property<int?>("GridReference")
+ .HasColumnType("integer");
+
+ b.Property<string>("JobType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<string>("MapGrid")
+ .HasColumnType("text");
+
+ b.Property<int?>("MapNo")
+ .HasColumnType("integer");
+
+ b.Property<int?>("MapType")
+ .HasColumnType("integer");
+
+ b.Property<string>("Note")
+ .HasColumnType("text");
+
+ b.Property<string>("PageDestination")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<List<string>>("PagedServices")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property<int>("PagerMessage")
+ .HasColumnType("integer");
+
+ b.HasKey("ParsedPagerMessageId");
+
+ b.HasIndex("FirecomJobNo");
+
+ b.HasIndex("GpsPositionId");
+
+ b.HasIndex("PagerMessage")
+ .IsUnique();
+
+ b.ToTable("ParsedPagerMessages");
+ });
+
+ modelBuilder.Entity("PagerParser.ParsedPagerMessage", b =>
+ {
+ b.HasOne("PagerParser.GpsPosition", "GpsPosition")
+ .WithMany()
+ .HasForeignKey("GpsPositionId");
+
+ b.HasOne("PagerParser.PagerMessage", "OriginalMessage")
+ .WithOne("ParsedMessage")
+ .HasForeignKey("PagerParser.ParsedPagerMessage", "PagerMessage")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("GpsPosition");
+
+ b.Navigation("OriginalMessage");
+ });
+
+ modelBuilder.Entity("PagerParser.PagerMessage", b =>
+ {
+ b.Navigation("ParsedMessage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Migrations/20230806155332_PagedServices.cs b/Migrations/20230806155332_PagedServices.cs
new file mode 100644
index 0000000..b58e85b
--- /dev/null
+++ b/Migrations/20230806155332_PagedServices.cs
@@ -0,0 +1,30 @@
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PagerParser.Migrations
+{
+ /// <inheritdoc />
+ public partial class PagedServices : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<List<string>>(
+ name: "PagedServices",
+ table: "ParsedPagerMessages",
+ type: "text[]",
+ defaultValue: new List<string>(),
+ nullable: false);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "PagedServices",
+ table: "ParsedPagerMessages");
+ }
+ }
+}
diff --git a/Migrations/20230807001127_IndexPageDestination.Designer.cs b/Migrations/20230807001127_IndexPageDestination.Designer.cs
new file mode 100644
index 0000000..c9b163c
--- /dev/null
+++ b/Migrations/20230807001127_IndexPageDestination.Designer.cs
@@ -0,0 +1,169 @@
+// <auto-generated />
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PagerParser;
+
+#nullable disable
+
+namespace PagerParser.Migrations
+{
+ [DbContext(typeof(PagerContext))]
+ [Migration("20230807001127_IndexPageDestination")]
+ partial class IndexPageDestination
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "7.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("PagerParser.GpsPosition", b =>
+ {
+ b.Property<int>("GpsPositionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GpsPositionId"));
+
+ b.Property<double>("Latitude")
+ .HasColumnType("double precision");
+
+ b.Property<double>("Longitude")
+ .HasColumnType("double precision");
+
+ b.HasKey("GpsPositionId");
+
+ b.ToTable("GpsPositions");
+ });
+
+ modelBuilder.Entity("PagerParser.PagerMessage", b =>
+ {
+ b.Property<int>("PagerMessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PagerMessageId"));
+
+ b.Property<string>("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<DateTime>("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("PagerMessageId");
+
+ b.HasIndex("Message");
+
+ b.ToTable("PagerMessages");
+ });
+
+ modelBuilder.Entity("PagerParser.ParsedPagerMessage", b =>
+ {
+ b.Property<int>("ParsedPagerMessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ParsedPagerMessageId"));
+
+ b.Property<int>("AlertLevel")
+ .HasColumnType("integer");
+
+ b.Property<string>("AssignmentArea")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<int>("AttendingServices")
+ .HasColumnType("integer");
+
+ b.Property<string>("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<int?>("FireGroundChannel")
+ .HasColumnType("integer");
+
+ b.Property<int>("FirecomJobNo")
+ .HasColumnType("integer");
+
+ b.Property<int?>("GpsPositionId")
+ .HasColumnType("integer");
+
+ b.Property<int?>("GridReference")
+ .HasColumnType("integer");
+
+ b.Property<string>("JobType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<string>("MapGrid")
+ .HasColumnType("text");
+
+ b.Property<int?>("MapNo")
+ .HasColumnType("integer");
+
+ b.Property<int?>("MapType")
+ .HasColumnType("integer");
+
+ b.Property<string>("Note")
+ .HasColumnType("text");
+
+ b.Property<string>("PageDestination")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<List<string>>("PagedServices")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property<int>("PagerMessage")
+ .HasColumnType("integer");
+
+ b.HasKey("ParsedPagerMessageId");
+
+ b.HasIndex("FirecomJobNo");
+
+ b.HasIndex("GpsPositionId");
+
+ b.HasIndex("PageDestination");
+
+ b.HasIndex("PagerMessage")
+ .IsUnique();
+
+ b.ToTable("ParsedPagerMessages");
+ });
+
+ modelBuilder.Entity("PagerParser.ParsedPagerMessage", b =>
+ {
+ b.HasOne("PagerParser.GpsPosition", "GpsPosition")
+ .WithMany()
+ .HasForeignKey("GpsPositionId");
+
+ b.HasOne("PagerParser.PagerMessage", "OriginalMessage")
+ .WithOne("ParsedMessage")
+ .HasForeignKey("PagerParser.ParsedPagerMessage", "PagerMessage")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("GpsPosition");
+
+ b.Navigation("OriginalMessage");
+ });
+
+ modelBuilder.Entity("PagerParser.PagerMessage", b =>
+ {
+ b.Navigation("ParsedMessage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Migrations/20230807001127_IndexPageDestination.cs b/Migrations/20230807001127_IndexPageDestination.cs
new file mode 100644
index 0000000..0564a5f
--- /dev/null
+++ b/Migrations/20230807001127_IndexPageDestination.cs
@@ -0,0 +1,27 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace PagerParser.Migrations
+{
+ /// <inheritdoc />
+ public partial class IndexPageDestination : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateIndex(
+ name: "IX_ParsedPagerMessages_PageDestination",
+ table: "ParsedPagerMessages",
+ column: "PageDestination");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "IX_ParsedPagerMessages_PageDestination",
+ table: "ParsedPagerMessages");
+ }
+ }
+}
diff --git a/Migrations/20241011050136_v0.4-rc1.Designer.cs b/Migrations/20241011050136_v0.4-rc1.Designer.cs
new file mode 100644
index 0000000..8c427ed
--- /dev/null
+++ b/Migrations/20241011050136_v0.4-rc1.Designer.cs
@@ -0,0 +1,222 @@
+// <auto-generated />
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using PagerParser;
+
+#nullable disable
+
+namespace PagerParser.Migrations
+{
+ [DbContext(typeof(PagerContext))]
+ [Migration("20241011050136_v0.4-rc1")]
+ partial class v04rc1
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("PagerParser.GpsPosition", b =>
+ {
+ b.Property<int>("GpsPositionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("GpsPositionId"));
+
+ b.Property<double>("Latitude")
+ .HasColumnType("double precision");
+
+ b.Property<double>("Longitude")
+ .HasColumnType("double precision");
+
+ b.HasKey("GpsPositionId");
+
+ b.ToTable("GpsPositions");
+ });
+
+ modelBuilder.Entity("PagerParser.Handlers.DiscordAlertChannel", b =>
+ {
+ b.Property<int>("DiscordAlertChannelId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DiscordAlertChannelId"));
+
+ b.Property<decimal>("ChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property<decimal>("GuildId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property<bool>("RequireMention")
+ .HasColumnType("boolean");
+
+ b.HasKey("DiscordAlertChannelId");
+
+ b.HasIndex("GuildId")
+ .IsUnique();
+
+ b.ToTable("DiscordAlertChannels");
+ });
+
+ modelBuilder.Entity("PagerParser.Handlers.DiscordAlertSubscription", b =>
+ {
+ b.Property<int>("DiscordAlertSubscriptionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DiscordAlertSubscriptionId"));
+
+ b.Property<decimal>("GuildId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property<string>("PageDestination")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<decimal>("PrincipalId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property<int>("PrincipalType")
+ .HasColumnType("integer");
+
+ b.HasKey("DiscordAlertSubscriptionId");
+
+ b.HasIndex("GuildId");
+
+ b.ToTable("DiscordAlertSubscriptions");
+ });
+
+ modelBuilder.Entity("PagerParser.PagerMessage", b =>
+ {
+ b.Property<int>("PagerMessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("PagerMessageId"));
+
+ b.Property<string>("Message")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<DateTime>("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("PagerMessageId");
+
+ b.HasIndex("Message");
+
+ b.ToTable("PagerMessages");
+ });
+
+ modelBuilder.Entity("PagerParser.ParsedPagerMessage", b =>
+ {
+ b.Property<int>("ParsedPagerMessageId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ParsedPagerMessageId"));
+
+ b.Property<int>("AlertLevel")
+ .HasColumnType("integer");
+
+ b.Property<string>("AssignmentArea")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<int>("AttendingServices")
+ .HasColumnType("integer");
+
+ b.Property<string>("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<int?>("FireGroundChannel")
+ .HasColumnType("integer");
+
+ b.Property<int>("FirecomJobNo")
+ .HasColumnType("integer");
+
+ b.Property<int?>("GpsPositionId")
+ .HasColumnType("integer");
+
+ b.Property<int?>("GridReference")
+ .HasColumnType("integer");
+
+ b.Property<string>("JobType")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<string>("MapGrid")
+ .HasColumnType("text");
+
+ b.Property<int?>("MapNo")
+ .HasColumnType("integer");
+
+ b.Property<int?>("MapType")
+ .HasColumnType("integer");
+
+ b.Property<string>("Note")
+ .HasColumnType("text");
+
+ b.Property<string>("PageDestination")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<List<string>>("PagedServices")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.Property<int>("PagerMessage")
+ .HasColumnType("integer");
+
+ b.HasKey("ParsedPagerMessageId");
+
+ b.HasIndex("FirecomJobNo");
+
+ b.HasIndex("GpsPositionId");
+
+ b.HasIndex("PageDestination");
+
+ b.HasIndex("PagerMessage")
+ .IsUnique();
+
+ b.ToTable("ParsedPagerMessages");
+ });
+
+ modelBuilder.Entity("PagerParser.ParsedPagerMessage", b =>
+ {
+ b.HasOne("PagerParser.GpsPosition", "GpsPosition")
+ .WithMany()
+ .HasForeignKey("GpsPositionId");
+
+ b.HasOne("PagerParser.PagerMessage", "OriginalMessage")
+ .WithOne("ParsedMessage")
+ .HasForeignKey("PagerParser.ParsedPagerMessage", "PagerMessage")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("GpsPosition");
+
+ b.Navigation("OriginalMessage");
+ });
+
+ modelBuilder.Entity("PagerParser.PagerMessage", b =>
+ {
+ b.Navigation("ParsedMessage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Migrations/20241011050136_v0.4-rc1.cs b/Migrations/20241011050136_v0.4-rc1.cs
new file mode 100644
index 0000000..6278225
--- /dev/null
+++ b/Migrations/20241011050136_v0.4-rc1.cs
@@ -0,0 +1,67 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace PagerParser.Migrations
+{
+ /// <inheritdoc />
+ public partial class v04rc1 : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DiscordAlertChannels",
+ columns: table => new
+ {
+ DiscordAlertChannelId = table.Column<int>(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ GuildId = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
+ ChannelId = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
+ RequireMention = table.Column<bool>(type: "boolean", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DiscordAlertChannels", x => x.DiscordAlertChannelId);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "DiscordAlertSubscriptions",
+ columns: table => new
+ {
+ DiscordAlertSubscriptionId = table.Column<int>(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ PageDestination = table.Column<string>(type: "text", nullable: false),
+ GuildId = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
+ PrincipalId = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
+ PrincipalType = table.Column<int>(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DiscordAlertSubscriptions", x => x.DiscordAlertSubscriptionId);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DiscordAlertChannels_GuildId",
+ table: "DiscordAlertChannels",
+ column: "GuildId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DiscordAlertSubscriptions_GuildId",
+ table: "DiscordAlertSubscriptions",
+ column: "GuildId");
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DiscordAlertChannels");
+
+ migrationBuilder.DropTable(
+ name: "DiscordAlertSubscriptions");
+ }
+ }
+}
diff --git a/Migrations/PagerContextModelSnapshot.cs b/Migrations/PagerContextModelSnapshot.cs
index 879c13f..4ff5dc3 100644
--- a/Migrations/PagerContextModelSnapshot.cs
+++ b/Migrations/PagerContextModelSnapshot.cs
@@ -1,5 +1,6 @@
// <auto-generated />
using System;
+using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -17,7 +18,7 @@ namespace PagerParser.Migrations
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "7.0.8")
+ .HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -41,6 +42,59 @@ namespace PagerParser.Migrations
b.ToTable("GpsPositions");
});
+ modelBuilder.Entity("PagerParser.Handlers.DiscordAlertChannel", b =>
+ {
+ b.Property<int>("DiscordAlertChannelId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DiscordAlertChannelId"));
+
+ b.Property<decimal>("ChannelId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property<decimal>("GuildId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property<bool>("RequireMention")
+ .HasColumnType("boolean");
+
+ b.HasKey("DiscordAlertChannelId");
+
+ b.HasIndex("GuildId")
+ .IsUnique();
+
+ b.ToTable("DiscordAlertChannels");
+ });
+
+ modelBuilder.Entity("PagerParser.Handlers.DiscordAlertSubscription", b =>
+ {
+ b.Property<int>("DiscordAlertSubscriptionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DiscordAlertSubscriptionId"));
+
+ b.Property<decimal>("GuildId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property<string>("PageDestination")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property<decimal>("PrincipalId")
+ .HasColumnType("numeric(20,0)");
+
+ b.Property<int>("PrincipalType")
+ .HasColumnType("integer");
+
+ b.HasKey("DiscordAlertSubscriptionId");
+
+ b.HasIndex("GuildId");
+
+ b.ToTable("DiscordAlertSubscriptions");
+ });
+
modelBuilder.Entity("PagerParser.PagerMessage", b =>
{
b.Property<int>("PagerMessageId")
@@ -101,10 +155,13 @@ namespace PagerParser.Migrations
.IsRequired()
.HasColumnType("text");
- b.Property<string>("MelwaysGrid")
+ b.Property<string>("MapGrid")
.HasColumnType("text");
- b.Property<int?>("MelwaysMapNo")
+ b.Property<int?>("MapNo")
+ .HasColumnType("integer");
+
+ b.Property<int?>("MapType")
.HasColumnType("integer");
b.Property<string>("Note")
@@ -114,6 +171,10 @@ namespace PagerParser.Migrations
.IsRequired()
.HasColumnType("text");
+ b.Property<List<string>>("PagedServices")
+ .IsRequired()
+ .HasColumnType("text[]");
+
b.Property<int>("PagerMessage")
.HasColumnType("integer");
@@ -123,6 +184,8 @@ namespace PagerParser.Migrations
b.HasIndex("GpsPositionId");
+ b.HasIndex("PageDestination");
+
b.HasIndex("PagerMessage")
.IsUnique();
diff --git a/PagerContext.cs b/PagerContext.cs
index 73e675f..0466622 100644
--- a/PagerContext.cs
+++ b/PagerContext.cs
@@ -2,7 +2,7 @@
namespace PagerParser;
-public class PagerContext : DbContext {
+public partial class PagerContext : DbContext {
public DbSet<PagerMessage> PagerMessages { get; set; }
public DbSet<ParsedPagerMessage> ParsedPagerMessages { get; set; }
public DbSet<GpsPosition> GpsPositions { get; set; }
diff --git a/PagerFetchService.cs b/PagerFetchService.cs
index 98723aa..aa014a3 100644
--- a/PagerFetchService.cs
+++ b/PagerFetchService.cs
@@ -10,8 +10,9 @@ public class PagerMessageEventArgs : EventArgs {
public delegate void PagerMessageHandler(object sender, PagerMessageEventArgs e);
public interface IPagerProvider {
- public event EventHandler OnConnect;
- public event PagerMessageHandler OnPagerMessage;
+ public event EventHandler OnConnect;
+ public event EventHandler<string> OnDisconnect;
+ public event PagerMessageHandler OnPagerMessage;
public void Connect();
public void Disconnect();
@@ -23,12 +24,17 @@ public interface IPagerProvider {
public class PagerProviderAttribute : Attribute {}
public class PagerFetchService : IHostedService {
- private const int DefaultFetchLimit = 500;
- private readonly TimeSpan MinFetchInterval = TimeSpan.FromMinutes(10);
+ private readonly TimeSpan FetchInterval = TimeSpan.FromMinutes(1);
+
+ private System.Timers.Timer fetchTimer;
private IPagerProvider pagerProvider = new PagerMon();
- private DateTime? LastFetch;
+ private IRootPagerHandler rootPagerHandler;
+
+ private DateTime? lastFetch;
+
+ private SemaphoreSlim lastFetchLock;
private Regex[] MessageInclude;
private Regex[] MessageExclude;
@@ -44,12 +50,19 @@ public class PagerFetchService : IHostedService {
IConfiguration config,
ILogger<PagerFetchService> logger,
IServiceProvider serviceProvider,
+ IRootPagerHandler rootPagerHandler,
IPagerMessageParserService parser) {
- this.config = config;
- this.logger = logger;
- this.serviceProvider = serviceProvider;
- this.parser = parser;
+ this.config = config;
+ this.logger = logger;
+ this.serviceProvider = serviceProvider;
+ this.rootPagerHandler = rootPagerHandler;
+ this.parser = parser;
+
+ fetchTimer = new() {
+ Interval = FetchInterval.TotalMilliseconds
+ };
+ fetchTimer.Elapsed += (obj, e) => FetchAsync().GetAwaiter().GetResult();
// Pre-compile regex message filters if configured
MessageInclude = config.GetValue<string[]>("PagerParser:MessageInclude")?
@@ -62,17 +75,28 @@ public class PagerFetchService : IHostedService {
public Task StartAsync(CancellationToken cancellationToken) {
logger.LogInformation("Pager fetch service starting...");
+ lastFetchLock = new(1, 1);
+ fetchTimer.AutoReset = true;
+ fetchTimer.Start();
cts = new();
+ cancellationToken.Register(() => cts.Cancel());
pagerProvider.OnConnect += OnConnect;
+ pagerProvider.OnDisconnect += OnDisconnect;
pagerProvider.OnPagerMessage += PagerMessageReceived;
pagerProvider.Connect();
+
+ _ = Task.Run(FetchAsync);
+
return Task.CompletedTask;
}
- public Task StopAsync(CancellationToken cancellationToken) {
+ public Task StopAsync(CancellationToken ct) {
logger.LogInformation("Pager fetch service stopping...");
+ lastFetchLock.Dispose();
+ fetchTimer.Stop();
cts.Cancel();
pagerProvider.OnConnect -= OnConnect;
+ pagerProvider.OnDisconnect -= OnDisconnect;
pagerProvider.OnPagerMessage -= PagerMessageReceived;
pagerProvider.Disconnect();
return Task.CompletedTask;
@@ -114,29 +138,64 @@ public class PagerFetchService : IHostedService {
db.SaveChanges();
}
- // If we were disconnected, or have just started up, attempt to
- // fetch a bulk of messages in case we missed any. We store the
- // time of the last successful fetch to ensure we don't fetch
- // unless we've been disconnected for a significant amount of
- // time (or have just started up).
- public async void OnConnect(object? sender, EventArgs e) {
- var fetchUntil = LastFetch ?? LastPageTimestamp ?? DateTime.MinValue;
+ public async Task FetchAsync() {
+ await lastFetchLock.WaitAsync(cts.Token);
+ try {
+ var fetchUntil = lastFetch ?? LastPageTimestamp ?? DateTime.MinValue;
- logger.LogInformation($"Fetching messages up to {fetchUntil}");
+ logger.LogInformation($"Fetching messages up to {fetchUntil}");
+ try {
+ var messages = await pagerProvider.FetchAsync(cts.Token, fetchUntil);
+ logger.LogInformation($"Fetched {messages.Count()} message(s)");
+ AddMessages(messages);
+ // If our provider is currently connected and receiving messages
+ // in realtime, we can set lastFetch to null, allowing it to be
+ // updated when the next disconnect occurs. Otherwise if we're
+ // currently disconnected, store the current time as the last
+ // fetch, in preparation for the next fetch cycle.
+ lastFetch = fetchTimer.AutoReset ? DateTime.Now : null;
+ } catch {
+ logger.LogError("Failed to fetch messages");
+ }
+ } finally {
+ lastFetchLock.Release();
+ }
+ }
+
+ public void OnConnect(object? sender, EventArgs e) {
+ logger.LogInformation(
+ $"Connected to pager message provider {pagerProvider.GetType().Name}");
+
+ // Stop polling, allowing any pending fetch to proceed to get messages
+ // potentially missed whilst the stream was disconnected.
+ fetchTimer.AutoReset = false;
+ }
+
+ public async void OnDisconnect(object? sender, string e) {
+ logger.LogInformation(
+ $"Disconnected from pager message provider {pagerProvider.GetType().Name}");
+
+ await lastFetchLock.WaitAsync(cts.Token);
try {
- var messages = await pagerProvider.FetchAsync(cts.Token, fetchUntil);
- logger.LogInformation($"Fetched {messages.Count()} message(s)");
- AddMessages(messages);
- LastFetch = DateTime.Now;
- } catch {
- logger.LogError("Failed to fetch messages");
+ lastFetch ??= DateTime.Now;
+ } finally {
+ lastFetchLock.Release();
}
+
+ // Fallback to polling until the stream is re-established
+ fetchTimer.AutoReset = true;
+ fetchTimer.Start();
}
- public void PagerMessageReceived(object sender, PagerMessageEventArgs e) {
+ public async void PagerMessageReceived(object sender, PagerMessageEventArgs e) {
logger.LogInformation($"PagerMessage: {e.Message.Message}");
- AddMessages(new[] { e.Message });
+
+ AddMessages([ e.Message ]);
+
+ await rootPagerHandler.HandleMessageAsync(
+ e.Message,
+ parser.TryParse(e.Message.Message));
}
private DateTime? LastPageTimestamp {
diff --git a/PagerHandler.cs b/PagerHandler.cs
new file mode 100644
index 0000000..8520064
--- /dev/null
+++ b/PagerHandler.cs
@@ -0,0 +1,125 @@
+using System.Reflection;
+
+namespace PagerParser;
+
+[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
+public class PagerHandlerAttribute : Attribute {}
+
+public interface IPagerHandler {
+ public void OnConfiguring(
+ ILogger logger,
+ IConfiguration config,
+ IServiceProvider serviceProvider);
+
+ public Task StartAsync(CancellationToken ct);
+ public Task StopAsync(CancellationToken ct);
+
+ public Task HandleMessageAsync(
+ PagerMessage message,
+ ParsedPagerMessage? parsedMessage);
+}
+
+public interface IRootPagerHandler {
+ public Task HandleMessageAsync(
+ PagerMessage message,
+ ParsedPagerMessage? parsedMessage);
+}
+
+public class RootPagerHandler : IRootPagerHandler, IHostedService {
+ private readonly HandlerEntry[] handlers;
+
+ private readonly ILogger<RootPagerHandler> logger;
+
+ public RootPagerHandler(IServiceProvider serviceProvider) {
+ handlers = typeof(RootPagerHandler).Assembly.GetTypes()
+ .Where(t => t.IsClass)
+ .Where(t => t.IsAssignableTo(typeof(IPagerHandler)))
+ .Where(t => t.GetCustomAttributes<PagerHandlerAttribute>().Any())
+ .Select(t => Activator.CreateInstance(t))
+ .Cast<IPagerHandler>()
+ .Select(h => new HandlerEntry(h))
+ .ToArray();
+
+ var config = serviceProvider.GetRequiredService<IConfiguration>();
+
+ logger = serviceProvider.GetRequiredService<ILogger<RootPagerHandler>>();
+
+ foreach(var entry in handlers) {
+ var name = entry.Handler.GetType().Name;
+ var loggerType = typeof(ILogger<>).MakeGenericType(entry.GetType());
+ var logger = serviceProvider.GetRequiredService(loggerType);
+ try {
+ this.logger.LogDebug($"Configuring page handler: {name}");
+ entry.Handler.OnConfiguring((ILogger) logger, config, serviceProvider);
+ } catch {
+ // TODO: include exceptions in log on failure
+ this.logger.LogWarning($"Failed to configure page handler: {name}");
+ entry.State = HandlerState.Failed;
+ }
+ }
+ }
+
+ public async Task StartAsync(CancellationToken ct) {
+ logger.LogInformation("Root pager handler service starting...");
+ await Task.WhenAll(
+ handlers.Select(async e => {
+ var name = e.Handler.GetType().Name;
+ if(e.State != HandlerState.Stopped)
+ return;
+ logger.LogDebug($"Starting page handler: {name}");
+ try {
+ await e.Handler.StartAsync(ct);
+ e.State = HandlerState.Started;
+ } catch {
+ logger.LogWarning($"Failed to start page handler: {name}");
+ e.State = HandlerState.Failed;
+ }
+ }).ToArray());
+ }
+
+ public async Task StopAsync(CancellationToken ct) {
+ logger.LogInformation("Root pager handler service stopping...");
+ await Task.WhenAll(
+ handlers.Select(async e => {
+ var name = e.Handler.GetType().Name;
+ if(e.State != HandlerState.Started)
+ return;
+ logger.LogDebug($"Stopping page handler: {name}");
+ try {
+ await e.Handler.StopAsync(ct);
+ e.State = HandlerState.Stopped;
+ } catch {
+ logger.LogWarning($"Failed to stop page handler: {name}");
+ e.State = HandlerState.Failed;
+ }
+ }).ToArray());
+ }
+
+ public async Task HandleMessageAsync(PagerMessage m, ParsedPagerMessage? pm) {
+ await Task.WhenAll(
+ handlers
+ .Where(e => e.State == HandlerState.Started)
+ .Select(async e => {
+ var name = e.Handler.GetType().Name;
+ try {
+ await e.Handler.HandleMessageAsync(m, pm);
+ } catch {
+ logger.LogWarning($"Page handler {name} failed to handle pager message");
+ }
+ }).ToArray());
+ }
+
+ private record HandlerEntry {
+ public IPagerHandler Handler { get; set; }
+ public HandlerState State { get; set; } = HandlerState.Stopped;
+
+ public HandlerEntry(IPagerHandler handler) =>
+ Handler = handler;
+ }
+
+ private enum HandlerState {
+ Stopped,
+ Started,
+ Failed
+ }
+}
diff --git a/PagerMessage.cs b/PagerMessage.cs
index b817d23..765636c 100644
--- a/PagerMessage.cs
+++ b/PagerMessage.cs
@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore;
-using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
+using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace PagerParser;
@@ -21,6 +21,7 @@ public enum EmergencyService {
[Index(nameof(Message))]
public class PagerMessage {
+ [JsonIgnore]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int PagerMessageId { get; set; }
public DateTime Timestamp { get; set; }
@@ -32,8 +33,19 @@ public class PagerMessage {
(Timestamp, Message).GetHashCode();
}
+public enum MapType {
+ Melways = 1,
+ SpacialVisionCentral = 2,
+ SpacialVisionNorthEast = 3,
+ SpacialVisionNorthWest = 4,
+ SpacialVisionSouthEast = 5,
+ SpacialVisionSouthWest = 6
+}
+
[Index(nameof(FirecomJobNo))]
+[Index(nameof(PageDestination))]
public class ParsedPagerMessage {
+ [JsonIgnore]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int ParsedPagerMessageId { get; set; }
public int FirecomJobNo { get; set; }
@@ -41,16 +53,19 @@ public class ParsedPagerMessage {
public string JobType { get; set; }
public AlertLevel AlertLevel { get; set; }
public string Description { get; set; }
- public int? MelwaysMapNo { get; set; }
- public string? MelwaysGrid { get; set; }
+ public MapType? MapType { get; set; }
+ public int? MapNo { get; set; }
+ public string? MapGrid { get; set; }
public int? GridReference { get; set; }
public EmergencyService AttendingServices { get; set; }
public string? Note { get; set; }
public int? FireGroundChannel { get; set; }
+ public List<string> PagedServices { get; set; } = new();
public string PageDestination { get; set; }
public virtual GpsPosition? GpsPosition { get; set; }
+ [JsonIgnore]
[ForeignKey(nameof(PagerMessage))]
public virtual PagerMessage OriginalMessage { get; set; }
}
@@ -67,10 +82,23 @@ public interface IPagerMessageParserService {
// Running a single instance of the service isn't an issue, as this
// service is only used by the fetch service for parsing new messages.
public class PagerMessageParserService : IPagerMessageParserService {
+ public static readonly Dictionary<string, MapType> MapTypeMap = new() {
+ { "M", MapType.Melways },
+ { "SVC", MapType.SpacialVisionCentral },
+ { "SVNE", MapType.SpacialVisionNorthEast },
+ { "SVNW", MapType.SpacialVisionNorthWest },
+ { "SVSE", MapType.SpacialVisionSouthEast },
+ { "SVSW", MapType.SpacialVisionSouthWest },
+ };
+
private const string Pattern =
- @"^@@ALERT\s+([A-Z]*[0-9]+)\s+([A-Z&]+)C([13])\s+(\*\s+)?(.*)\s+M\s+(\d+)\s+([A-Z]\d+)\s+\((\d+)\)\s+(\*\s+[^*]+\*\s+)?([AFPRS]+)\s+(([A-Z0-9]+\s)+)F([0-9]+)\s+\[([A-Z]+)\]$";
+ @"^@@ALERT\s+([A-Z]*[0-9]+)\s+([A-Z&]+)C([13])\s+(\*\s+)?(.*)\s+(M|SVC|SVNE|SVNW|SVSE|SVSW)\s+(\d+)\s+([A-Z]\d+)\s+\((\d+)\)\s+(\*\s+[^*]+\*\s+)?([AFPRS]+)\s+(([A-Z0-9]+\s)+)F([0-9]+)\s+\[([A-Z0-9]+)[_]?\]$";
+
+ private Regex pageMessageRegex =
+ new Regex(Pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
- private Regex pageMessageRegex = new Regex(Pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private Regex firegroundRegex =
+ new Regex("^FGD([0-9]+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public ParsedPagerMessage Parse(string message) {
ParsedPagerMessage m = new();
@@ -84,25 +112,38 @@ public class PagerMessageParserService : IPagerMessageParserService {
m.AssignmentArea = match.Groups[1].Value;
m.JobType = match.Groups[2].Value;
m.Description = match.Groups[5].Value;
- m.FirecomJobNo = Int32.Parse(match.Groups[13].Value);
- m.PageDestination = match.Groups[14].Value;
+ m.FirecomJobNo = Int32.Parse(match.Groups[14].Value);
+ m.PageDestination = match.Groups[15].Value;
// Parse optional message components
- if(match.Groups[6].Success) m.MelwaysMapNo = Int32.Parse(match.Groups[6].Value);
- if(match.Groups[7].Success) m.MelwaysGrid = match.Groups[7].Value;
- if(match.Groups[8].Success) m.GridReference = Int32.Parse(match.Groups[8].Value);
- if(match.Groups[9].Success) m.Note = match.Groups[9].Value;
+ if(match.Groups[7].Success) m.MapNo = Int32.Parse(match.Groups[7].Value);
+ if(match.Groups[8].Success) m.MapGrid = match.Groups[8].Value;
+ if(match.Groups[9].Success) m.GridReference = Int32.Parse(match.Groups[9].Value);
+ if(match.Groups[10].Success) m.Note = match.Groups[10].Value;
+
+ // Parse map type using map type dictionary
+ if(match.Groups[6].Success) {
+ MapTypeMap.TryGetValue(match.Groups[6].Value, out var mapType);
+ m.MapType = mapType;
+ }
+
+ var pagedServicesField = match.Groups[12].Value.Split(" ");
// Attempt to extract the fire-ground channel as it can be mixed
// into the list of paged services.
- m.FireGroundChannel = match.Groups[11].Value
- .Split(" ")
- .Select(x => Regex.Match(x, "^FGD([0-9]+)$"))
+ m.FireGroundChannel = pagedServicesField
+ .Select(x => firegroundRegex.Match(x))
.Where(m => m.Success)
.Select(m => (int?) int.Parse(m.Groups[1].Value))
.DefaultIfEmpty(null)
.First();
+ // Parse the paged services, ignoring the fireground channel
+ m.PagedServices = pagedServicesField
+ .Where(x => !firegroundRegex.IsMatch(x))
+ .Where(x => !string.IsNullOrEmpty(x))
+ .ToList();
+
switch(match.Groups[3].Value) {
case "1": m.AlertLevel = AlertLevel.Code1; break;
case "3": m.AlertLevel = AlertLevel.Code3; break;
@@ -110,7 +151,7 @@ public class PagerMessageParserService : IPagerMessageParserService {
}
// Handle each character to construct a bitmap of attending services
- foreach(char c in match.Groups[10].Value) {
+ foreach(char c in match.Groups[11].Value) {
switch(c) {
case 'A': m.AttendingServices |= EmergencyService.Ambulance; break;
case 'F': m.AttendingServices |= EmergencyService.Fire; break;
diff --git a/PagerParser.csproj b/PagerParser.csproj
index 8a16fe6..7a72caa 100644
--- a/PagerParser.csproj
+++ b/PagerParser.csproj
@@ -1,24 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
- <TargetFramework>net7.0</TargetFramework>
+ <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
- <Version>0.3</Version>
+ <Version>0.4-rc1</Version>
+ <UserSecretsId>77f0c394-8b38-418c-a13e-2dc7c57ff7b9</UserSecretsId>
</PropertyGroup>
<ItemGroup>
- <PackageReference Include="CoordinateSharp" Version="2.18.1.1" />
- <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.4" />
+ <PackageReference Include="CoordinateSharp" Version="2.24.2.1" />
+ <PackageReference Include="Discord.Net" Version="3.16.0" />
+ <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="SocketIOClient" Version="3.0.8" />
- <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
- <PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.8" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.8" />
- <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.8">
+ <PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="8.0.8" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
- <PackageReference Include="System.Configuration.ConfigurationManager" Version="7.0.0" />
+ <PackageReference Include="System.Configuration.ConfigurationManager" Version="8.0.0" />
</ItemGroup>
-</Project> \ No newline at end of file
+ <ItemGroup>
+ <Content Update="appsettings.Development.json">
+ <CopyToOutputDirectory>Never</CopyToOutputDirectory>
+ </Content>
+ </ItemGroup>
+
+ <PropertyGroup Condition="$(Configuration) == 'Release'">
+ <DebugType>none</DebugType>
+ <DebugSymbols>false</DebugSymbols>
+ </PropertyGroup>
+
+</Project>
diff --git a/Pages/JobCounter/Index.cshtml b/Pages/JobCounter/Index.cshtml
new file mode 100644
index 0000000..22f3a95
--- /dev/null
+++ b/Pages/JobCounter/Index.cshtml
@@ -0,0 +1,17 @@
+@page "{PageDestination}"
+@model IndexModel
+
+<link rel="stylesheet" type="text/css" href="@(nameof(PagerParser)).styles.css"/>
+
+<script type="text/javascript">
+ window.onload = () => {
+ var refreshInterval = @Html.Raw(Model.RefreshInterval);
+ if(refreshInterval != 0) {
+ setTimeout(() => window.location.reload(), refreshInterval * 1000);
+ }
+ }
+</script>
+
+<div>
+ <h1>@Model.TotalJobs</h1>
+</div>
diff --git a/Pages/JobCounter/Index.cshtml.cs b/Pages/JobCounter/Index.cshtml.cs
new file mode 100644
index 0000000..cec36fa
--- /dev/null
+++ b/Pages/JobCounter/Index.cshtml.cs
@@ -0,0 +1,74 @@
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using System.Text.RegularExpressions;
+
+namespace PagerParser.Pages;
+
+public class IndexModel : PageModel {
+ private readonly PagerContext db;
+ private readonly JobCounterConfiguration config = new();
+
+ private string pageDestination;
+
+ public IndexModel(PagerContext db, IConfiguration config) {
+ this.db = db;
+
+ config.Bind("PagerParser:JobCounter", this.config);
+ }
+
+ public int RefreshInterval => config.RefreshInterval ?? 0;
+ public string MessageFilter =>
+ $"^@@ALERT.*\\[{Regex.Escape(pageDestination)}\\]$";
+
+ public int TotalJobs {
+ get {
+ // Attempt to calculate the number of jobs attended based on the
+ // number of distinct FIRS report numbers for successfully-parsed
+ // pager messages plus the number of unparsed, but matched alert
+ // messages. We attempt to scale the number of unparsed messages
+ // based on the ratio of duplicate FIRS report numbers that appear
+ // in the parsed messages, to best predict and compensate for
+ // re-paged jobs, before they are added to the tally.
+
+ // TODO: filter parsed messages based on page destination
+
+ var date = new DateTime(DateTime.UtcNow.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ var matchedMessages = db.PagerMessages
+ .Where(m => m.Timestamp >= date)
+ .Where(m => Regex.IsMatch(m.Message, MessageFilter));
+ var parsedMessages = matchedMessages
+ .Where(m => m.ParsedMessage != null)
+ .Select(m => m.ParsedMessage!);
+
+ var matchedMessageCount = matchedMessages.Count();
+ var parsedMessageCount = parsedMessages.Count();
+
+ var jobNumberCount = parsedMessages
+ .Select(pm => pm.FirecomJobNo)
+ .Distinct()
+ .Count();
+
+ double uniqueJobRate =
+ (double) jobNumberCount / (double) parsedMessageCount;
+
+ // Scale the number of unparsed messages and add
+ // them to the tally of distinct FIRS job numbers
+ jobNumberCount +=
+ (int) Math.Round((matchedMessageCount - parsedMessageCount) * uniqueJobRate);
+
+ // Apply the manually-entered offset if needed
+ if(config.CountOffsets.TryGetValue(pageDestination, out int offset))
+ jobNumberCount += offset;
+
+ return jobNumberCount;
+ }
+ }
+
+ public override void OnPageHandlerExecuting(PageHandlerExecutingContext context) =>
+ pageDestination = (string) RouteData.Values["PageDestination"]!;
+
+ private record JobCounterConfiguration {
+ public int? RefreshInterval { get; set; } = 300;
+ public Dictionary<string, int> CountOffsets { get; set; } = new();
+ }
+}
diff --git a/Pages/JobCounter/Index.cshtml.css b/Pages/JobCounter/Index.cshtml.css
new file mode 100644
index 0000000..5b191c1
--- /dev/null
+++ b/Pages/JobCounter/Index.cshtml.css
@@ -0,0 +1,14 @@
+div {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ left: 50%;
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+}
+
+h1 {
+ font-family: 'Arial';
+ font-size: 28pt;
+}
diff --git a/Pages/Shared/_Layout.cshtml b/Pages/Shared/_Layout.cshtml
new file mode 100644
index 0000000..93eaab4
--- /dev/null
+++ b/Pages/Shared/_Layout.cshtml
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+ <base href="~/">
+ <head>
+ <meta charset="utf-8" />
+ <title>CFA Job Counter</title>
+ <link rel="stylesheet" type="text/css" href="/styles/global.css"/>
+ </head>
+ <body>
+ @RenderBody()
+ </body>
+</html> \ No newline at end of file
diff --git a/Pages/_ViewImports.cshtml b/Pages/_ViewImports.cshtml
new file mode 100644
index 0000000..ee61c0e
--- /dev/null
+++ b/Pages/_ViewImports.cshtml
@@ -0,0 +1,3 @@
+@using PagerParser
+@namespace PagerParser.Pages
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
diff --git a/Pages/_ViewStart.cshtml b/Pages/_ViewStart.cshtml
new file mode 100644
index 0000000..a5f1004
--- /dev/null
+++ b/Pages/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "_Layout";
+}
diff --git a/PositionCalculator.cs b/PositionCalculator.cs
index 4344e76..2140a88 100644
--- a/PositionCalculator.cs
+++ b/PositionCalculator.cs
@@ -1,16 +1,33 @@
using CoordinateSharp;
-using PagerParser;
-using System;
using System.ComponentModel.DataAnnotations.Schema;
+using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
namespace PagerParser;
public class GpsPosition {
+ [JsonIgnore]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int GpsPositionId { get; set; }
public double Latitude { get; set; }
public double Longitude { get; set; }
+
+ private const double EarthRadius = 6_371;
+
+ /// <summary>
+ /// Get the distance from this point to the specified coordinates
+ /// </summary>
+ public double GetDistance(double latitude, double longitude) =>
+ Math.Acos(
+ Math.Sin(Latitude) * Math.Sin(latitude) +
+ Math.Cos(Latitude) * Math.Cos(latitude) *
+ Math.Cos(longitude - Longitude)) * EarthRadius;
+
+ /// <summary>
+ /// Get the distance from this point to another
+ /// </summary>
+ public double GetDistance(GpsPosition position) =>
+ GetDistance(position.Latitude, position.Longitude);
}
public record MelwaysPage {
@@ -56,13 +73,15 @@ public class PositionCalculator {
};
public static GpsPosition? GetGpsPosition(ParsedPagerMessage message) {
- if(message.MelwaysMapNo is null || message.MelwaysGrid is null)
+ if(message.MapType != MapType.Melways)
+ return null;
+ if(message.MapNo is null || message.MapGrid is null)
return null;
- var page = MelwaysPages.FirstOrDefault(p => p.PageNo == message.MelwaysMapNo);
+ var page = MelwaysPages.FirstOrDefault(p => p.PageNo == message.MapNo);
if(page is null)
return null;
- var match = Regex.Match(message.MelwaysGrid.ToUpper(), "^([A-HJ-K])([0-9]+)$");
+ var match = Regex.Match(message.MapGrid.ToUpper(), "^([A-HJ-K])([0-9]+)$");
if(!match.Success)
return null;
diff --git a/Program.cs b/Program.cs
index ba53ccb..cc60d92 100644
--- a/Program.cs
+++ b/Program.cs
@@ -1,4 +1,4 @@
-using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
using System.Text.Json.Serialization;
namespace PagerParser;
@@ -8,6 +8,7 @@ public class Program {
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<PagerContext>();
+ builder.Services.AddRazorPages();
// This is necessary if we don't want enum values sent as integers in JSON objects
builder.Services.AddControllers().AddJsonOptions(o => {
@@ -19,12 +20,16 @@ public class Program {
// Add our custom services
builder.Services.AddSingleton<IPagerMessageParserService, PagerMessageParserService>();
+ builder.Services.AddSingleton<RootPagerHandler>();
+ builder.Services.AddSingleton<IRootPagerHandler>(p => p.GetRequiredService<RootPagerHandler>());
+ builder.Services.AddSingleton<IHostedService>(p => p.GetRequiredService<RootPagerHandler>());
builder.Services.AddHostedService<PagerFetchService>();
var app = builder.Build();
// Create a temporary scope so that we can access services during startup
using var scope = app.Services.CreateScope();
+ var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var parser = scope.ServiceProvider.GetRequiredService<IPagerMessageParserService>();
@@ -35,26 +40,40 @@ public class Program {
var needsMigration = db.Database.GetPendingMigrations().Any();
db.Database.Migrate();
- if(config.GetValue<bool>("PagerParser:ReparseAllOnStartup")) {
+ int c = 0;
+ if(needsMigration || config.GetValue<bool>("PagerParser:ReparseAllOnStartup")) {
+ logger.LogInformation("Reparsing all messages...");
db.ParsedPagerMessages.RemoveRange(
db.ParsedPagerMessages.ToArray());
db.SaveChanges();
foreach(var message in db.PagerMessages) {
message.ParsedMessage = parser.TryParse(message.Message);
- if(message.ParsedMessage is not null)
+ if(message.ParsedMessage is not null) {
+ c++;
message.ParsedMessage.GpsPosition =
PositionCalculator.GetGpsPosition(message.ParsedMessage);
+ }
}
db.SaveChanges();
+ logger.LogInformation($"Successfully reparsed {c}/{db.PagerMessages.Count()} messages");
} else if(config.GetValue<bool>("PagerParser:ReparseFailedOnStartup")) {
+ logger.LogInformation("Reparsing non-parsed messages...");
foreach(var message in db.PagerMessages.Where(m => m.ParsedMessage == null)) {
message.ParsedMessage = parser.TryParse(message.Message);
- if(message.ParsedMessage is not null)
+ if(message.ParsedMessage is not null) {
+ c++;
if(message.ParsedMessage.GpsPosition is null)
message.ParsedMessage.GpsPosition =
PositionCalculator.GetGpsPosition(message.ParsedMessage);
+ }
}
db.SaveChanges();
+ logger.LogInformation($"Successfully reparsed {c}/{db.PagerMessages.Count()} messages");
+ }
+
+ if(!app.Environment.IsDevelopment()) {
+ app.UseExceptionHandler("/Error");
+ app.UseHsts();
}
// Start the server
@@ -64,8 +83,11 @@ public class Program {
.AllowAnyHeader());
app.UseHttpsRedirection();
app.UseHsts();
- app.UseStaticFiles();
app.MapControllers();
+ app.UseStaticFiles();
+ app.UseRouting();
+ app.UseAuthorization();
+ app.MapRazorPages();
app.Run();
}
}
diff --git a/README.md b/README.md
index 041caa1..3f06d46 100644
--- a/README.md
+++ b/README.md
@@ -4,19 +4,29 @@ Pager Parser is a service designed to act as a "central hub" for pager messages.
# Features
- Flexible framework designed to receive pager messages from a variety of data sources such as [PagerMon](https://github.com/pagermon/pagermon) ([Jobyyy](https://jobyyy.net/))
-- Parsing of useful information from pager messages such as job type, paged services, etc
+- Parsing of useful information from pager messages such as job type, location, etc
- Storage of received pager messages for archival purposes
- Storage of parsed pager message data for archival as well as generating useful metrics
+- Integration with Home Assistent REST API for generating events on the event bus of configured Home Assistant servers
+- Discord bot that allows users to subscribe and unsubscribe from pager message notifications
# Building
### Pre-requisites
-To build the project, the .NET 7.0 SDK is required
+To build the project, the .NET 8.0 SDK is required, as well as Docker if building the project as a Docker container
+
+### Docker
+
+To build the project using [Docker](https://docker.com/), simply execute the following command from the project base directory:
+
+```sh
+docker build -t pagerparser .
+```
### Standalone
-To build the project, simply execute the following command from the project base directory:
+To build the project without Docker, simply execute the following command from the project base directory:
```sh
dotnet build -c Release
@@ -28,8 +38,14 @@ The resulting output files may be found in the `bin/Release` directory
### Configuration
-To configure the project, simply edit the `appsettings.json` file, populating the desired sections
+To configure the project, simply edit the `appsettings.json` file, populating the desired sections. If using Docker, configuration should be done in the `compose.yaml` file. The provided file may be used as a template.
+
+### Docker
+To deploy the project using Docker, simply execute the following command from the project base directory:
+```sh
+docker compose up -d
+```
### Standalone
Standalone deployments of the project are current not supported in any official capacity. It is possible to run the project as a standalone deployment by registering the main executable to be started automatically and persisted in the background. This may be achieved on Linux using a systemd unit file or on Windows as an auto-start program.
diff --git a/Sites/PagerMon.cs b/Sites/PagerMon.cs
index 4d19354..9dfd7df 100644
--- a/Sites/PagerMon.cs
+++ b/Sites/PagerMon.cs
@@ -1,5 +1,4 @@
using SocketIOClient;
-using System;
using System.Text.Json;
namespace PagerParser.PagerProviders;
@@ -22,8 +21,9 @@ public class PagerMon : IPagerProvider {
public PagerMonMessage[] Messages { get; set; }
}
- public event EventHandler OnConnect;
- public event PagerMessageHandler OnPagerMessage;
+ public event EventHandler OnConnect;
+ public event EventHandler<string> OnDisconnect;
+ public event PagerMessageHandler OnPagerMessage;
private const string QueryBaseUrl =
"https://jobyyy.net/api/messages";
@@ -34,14 +34,18 @@ public class PagerMon : IPagerProvider {
public PagerMon() {
socketIOClient = new SocketIO("https://jobyyy.net/", new SocketIOOptions() {
- Transport = SocketIOClient.Transport.TransportProtocol.WebSocket,
- AutoUpgrade = false,
- EIO = EngineIO.V3
+ AutoUpgrade = false,
+ ConnectionTimeout = TimeSpan.FromMinutes(15),
+ EIO = EngineIO.V3,
+ Transport = SocketIOClient.Transport.TransportProtocol.WebSocket
});
socketIOClient.OnConnected += (sender, e) =>
OnConnect?.Invoke(this, e);
+ socketIOClient.OnDisconnected += (sender, e) =>
+ OnDisconnect?.Invoke(this, e);
+
socketIOClient.On("messagePost", PagerMessageReceived);
}
diff --git a/appsettings.Development.json b/appsettings.Development.json
index 063ef36..a3ef3b4 100644
--- a/appsettings.Development.json
+++ b/appsettings.Development.json
@@ -1,6 +1,7 @@
{
"PagerParser": {
- "ReparseAllOnStartup": true
+ "ReparseAllOnStartup": false,
+ "ReparseFailedOnStartup": false
},
"Logging": {
"LogLevel": {
diff --git a/appsettings.json b/appsettings.json
index d7eb9a7..e50ae14 100644
--- a/appsettings.json
+++ b/appsettings.json
@@ -3,11 +3,44 @@
"ReparseAllOnStartup": false,
"ReparseFailedOnStartup": true,
"MessageInclude": [
- // Regex patterns to include
+ // Regex patterns to include globally
],
"MessageExclude": [
- // Regex patterns to exclude
+ // Regex patterns to exclude globally
]
+ //"Discord": {
+ // "Webhooks": [
+ // {
+ // "Url": "<webhook url>",
+ // "AlertGroups": [
+ // {
+ // "PageDestinations": [
+ // // List of destination brigades
+ // ],
+ // "Roles": [
+ // // List of Discord role IDs
+ // ]
+ // }
+ // ]
+ // }
+ // ]
+ //}
+ //"HomeAssistant": {
+ // "Servers": [
+ // {
+ // "Host": "example.com",
+ // "ApiKey": "<API key>",
+ // // optional event_type to use for the generated event
+ // "EventType": "<Event Type>"
+ // }
+ // ]
+ //}
+ //"JobCounter": {
+ // "RefreshInterval": 300,
+ // "CountOffsets": {
+ // "BRIG": 500
+ // }
+ //}
},
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Database=PagerParser;Username=pagerparser;Password=password"
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 0000000..821479d
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,26 @@
+services:
+ pagerparser:
+ build: .
+ image: pagerparser
+ restart: on-failure:10
+ ports:
+ - 5000:8080
+ depends_on:
+ - postgres
+ links:
+ - postgres
+ environment:
+ - "ConnectionStrings__DefaultConnection=Host=postgres;Database=PagerParser;Username=pagerparser;Password=A4ocHfq4XXww7MV9"
+ postgres:
+ image: postgres:16-alpine
+ ports:
+ - 5432:5432
+ volumes:
+ - db:/var/lib/postgresql/data
+ environment:
+ - POSTGRES_PASSWORD=A4ocHfq4XXww7MV9
+ - POSTGRES_USER=pagerparser
+ - POSTGRES_DB=PagerParser
+volumes:
+ db:
+ driver: local
diff --git a/wwwroot/styles/global.css b/wwwroot/styles/global.css
new file mode 100644
index 0000000..18ffdce
--- /dev/null
+++ b/wwwroot/styles/global.css
@@ -0,0 +1,4 @@
+body {
+ background: #13264E;
+ color: #ffffff;
+}