diff options
32 files changed, 2213 insertions, 78 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md Binary files differindex 5ef1e09..dc92823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md 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; @@ -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(); } } @@ -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; +} |
