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 DiscordAlertChannels { get; set; } public DbSet DiscordAlertSubscriptions { get; set; } } } namespace PagerParser.Handlers { [PagerHandler] internal class DiscordHandler : IPagerHandler { // TODO: consolidate this with the map used by TTS private readonly Dictionary 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 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. BERW, FS88, 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. BERW, FS88, 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(); 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 => s.GuildId == guild.Id) .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(); var alertChannel = db.DiscordAlertChannels .FirstOrDefault(c => c.GuildId == command.GuildId); if(alertChannel is null) { await command.RespondAsync( ephemeral: true, text: "No alert channel has been configured for this server!"); return; } await command.RespondAsync( ephemeral: true, text: 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(); 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( ephemeral: true, text: "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( ephemeral: true, text: 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(); await db.Database.BeginTransactionAsync(); var alertChannel = await db.DiscordAlertChannels .FirstOrDefaultAsync(c => c.GuildId == command.GuildId); if(alertChannel is null) { await command.RespondAsync( ephemeral: true, text: "No alert channel was configured for this server!"); return; } db.Remove(alertChannel); await db.SaveChangesAsync(); await db.Database.CommitTransactionAsync(); await command.RespondAsync( ephemeral: true, text: $"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(); 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( ephemeral: true, text: 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( ephemeral: true, text: 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(); 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( ephemeral: true, text: 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( ephemeral: true, text: 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(); 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( ephemeral: true, text: $"{mention} not subscribed to any pager messages"); return; } await command.RespondAsync( ephemeral: true, text: 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( ephemeral: true, text: 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("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 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 } }