diff options
| author | Jake Mannens <jake@asger.xyz> | 2024-09-20 16:21:09 +1000 |
|---|---|---|
| committer | Jake Mannens <jake@asger.xyz> | 2024-10-29 11:42:54 +1100 |
| commit | e8e3c4cba8ffa0056e984c113cfbb75319e00022 (patch) | |
| tree | 8336315e61f9e77207276d478b25fe5dc7c3d06c /Handlers/DiscordHandler.cs | |
| parent | 0e21907c76dbefed11f382bcf949143f0716567f (diff) | |
v0.4-rc1v0.4-rc1
Diffstat (limited to 'Handlers/DiscordHandler.cs')
| -rw-r--r-- | Handlers/DiscordHandler.cs | 626 |
1 files changed, 626 insertions, 0 deletions
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 + } +} |
