aboutsummaryrefslogtreecommitdiff
path: root/Handlers/DiscordHandler.cs
diff options
context:
space:
mode:
authorJake Mannens <jake@asger.xyz>2024-09-20 16:21:09 +1000
committerJake Mannens <jake@asger.xyz>2024-10-29 11:42:54 +1100
commite8e3c4cba8ffa0056e984c113cfbb75319e00022 (patch)
tree8336315e61f9e77207276d478b25fe5dc7c3d06c /Handlers/DiscordHandler.cs
parent0e21907c76dbefed11f382bcf949143f0716567f (diff)
v0.4-rc1v0.4-rc1
Diffstat (limited to 'Handlers/DiscordHandler.cs')
-rw-r--r--Handlers/DiscordHandler.cs626
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
+ }
+}