From e4d50555555304570286762b2bf0271e94d80fe4 Mon Sep 17 00:00:00 2001 From: Dominion Date: Fri, 14 Apr 2023 14:20:49 -0400 Subject: [PATCH] Fix Channel ID 0 on Discord Provider - Add support for 1-many channel mappings - Add handling for MissingAccess and UnknownChannel errors - Added support for Discord threads - Processing error messages now reply --- .../Components/Chat/ChatManager.cs | 48 +++- .../Chat/Providers/DiscordProvider.cs | 264 ++++++++++++------ .../Components/Chat/Providers/IProvider.cs | 4 +- .../Components/Chat/Providers/IrcProvider.cs | 63 +++-- .../Components/Chat/Providers/Provider.cs | 8 +- 5 files changed, 258 insertions(+), 129 deletions(-) diff --git a/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs b/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs index 7b204b0b05..97f966d5c2 100644 --- a/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs +++ b/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs @@ -212,15 +212,17 @@ namespace Tgstation.Server.Host.Components.Chat .ToList(); } - var newMappings = results.Select(tuple => new ChannelMapping - { - IsWatchdogChannel = tuple.Item1.IsWatchdogChannel == true, - IsUpdatesChannel = tuple.Item1.IsUpdatesChannel == true, - IsAdminChannel = tuple.Item1.IsAdminChannel == true, - ProviderChannelId = tuple.Item2.RealId, - ProviderId = connectionId, - Channel = tuple.Item2, - }); + var newMappings = results.SelectMany( + kvp => kvp.Value.Select( + channelRepresentation => new ChannelMapping + { + IsWatchdogChannel = kvp.Key.IsWatchdogChannel == true, + IsUpdatesChannel = kvp.Key.IsUpdatesChannel == true, + IsAdminChannel = kvp.Key.IsAdminChannel == true, + ProviderChannelId = channelRepresentation.RealId, + ProviderId = connectionId, + Channel = channelRepresentation, + })); ulong baseId; lock (synchronizationLock) @@ -594,10 +596,11 @@ namespace Tgstation.Server.Host.Components.Chat /// /// The who recevied . /// The to process. If , this indicates the provider reconnected. + /// If we are called recursively after remapping the provider. /// The for the operation. /// A representing the running operation. #pragma warning disable CA1502 - async Task ProcessMessage(IProvider provider, Message message, CancellationToken cancellationToken) + async Task ProcessMessage(IProvider provider, Message message, bool recursed, CancellationToken cancellationToken) #pragma warning restore CA1502 { if (!provider.Connected) @@ -617,6 +620,7 @@ namespace Tgstation.Server.Host.Components.Chat var providerChannelId = message.User.Channel.RealId; KeyValuePair? mappedChannel; long providerId; + bool hasChannelZero; lock (providers) { // important, otherwise we could end up processing during shutdown @@ -630,6 +634,18 @@ namespace Tgstation.Server.Host.Components.Chat .Where(x => x.Value.ProviderId == providerId && x.Value.ProviderChannelId == providerChannelId) .Select(x => (KeyValuePair?)x) .FirstOrDefault(); + hasChannelZero = mappedChannels + .Where(x => x.Value.ProviderId == providerId && x.Value.ProviderChannelId == 0) + .Any(); + } + + if (!recursed && !mappedChannel.HasValue && hasChannelZero) + { + logger.LogInformation("Receieved message from unmapped channel whose provider contains ID 0. Remapping..."); + await RemapProvider(provider, cancellationToken); + logger.LogTrace("Resume processing original message..."); + await ProcessMessage(provider, message, true, cancellationToken); + return; } if (message.User.Channel.IsPrivateChannel) @@ -646,11 +662,17 @@ namespace Tgstation.Server.Host.Components.Chat newId); mappedChannels.Add(newId, new ChannelMapping { - IsWatchdogChannel = false, ProviderChannelId = message.User.Channel.RealId, ProviderId = providerId, Channel = message.User.Channel, }); + + logger.LogTrace( + "Mapping DM {connectionName}:{userId} ({userFriendlyName}) as {newId}", + message.User.Channel.ConnectionName, + message.User.RealId, + message.User.FriendlyName, + newId); message.User.Channel.RealId = newId; } else @@ -668,7 +690,7 @@ namespace Tgstation.Server.Host.Components.Chat { message.User.Channel.RealId, }, - null, + message, new MessageContent { Text = "TGS: Processing error, check logs!", @@ -892,7 +914,7 @@ namespace Tgstation.Server.Host.Components.Chat using (LogContext.PushProperty("ChatMessage", messageNumber)) try { - await ProcessMessage(completedMessageTaskKvp.Key, message, cancellationToken); + await ProcessMessage(completedMessageTaskKvp.Key, message, false, cancellationToken); } catch (Exception ex) { diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs index cf5fdccaad..787b0a8e9f 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs @@ -13,10 +13,12 @@ using Remora.Discord.API.Abstractions.Gateway.Commands; using Remora.Discord.API.Abstractions.Gateway.Events; using Remora.Discord.API.Abstractions.Objects; using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Abstractions.Results; using Remora.Discord.API.Objects; using Remora.Discord.Gateway; using Remora.Discord.Gateway.Extensions; using Remora.Rest.Core; +using Remora.Rest.Results; using Remora.Results; using Tgstation.Server.Api.Models; @@ -48,6 +50,17 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } } + /// + /// The s supported by the for mapping. + /// + static readonly ChannelType[] SupportedGuildChannelTypes = new[] + { + ChannelType.GuildText, + ChannelType.GuildAnnouncement, + ChannelType.PrivateThread, + ChannelType.PublicThread, + }; + /// /// The for the . /// @@ -103,11 +116,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// Snowflake currentUserId; - /// - /// The bot's username at the time of connection. - /// - string initialUserName; - /// /// If is being disposed. /// @@ -263,37 +271,27 @@ namespace Tgstation.Server.Host.Components.Chat.Providers { if (channelId == 0) { - var usersClient = serviceProvider.GetRequiredService(); - var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken); - if (!currentGuildsResponse.IsSuccess) - { - Logger.LogWarning( - "Error retrieving current discord guilds: {result}", - currentGuildsResponse.LogFormat()); - return; - } - - var guildsClient = serviceProvider.GetRequiredService(); - - var guildsChannelsTasks = currentGuildsResponse.Entity.Select( - guild => guildsClient.GetGuildChannelsAsync(guild.ID.Value, cancellationToken)); - - await Task.WhenAll(guildsChannelsTasks); - - var unmappedTextChannels = guildsChannelsTasks - .Select(task => task.Result) - .SelectMany(guildChannels => guildChannels.Entity) - .Where(guildChannel => guildChannel.Type == ChannelType.GuildText); - + IEnumerable unmappedTextChannels; + var allAccessibleTextChannels = await GetAllAccessibleTextChannels(cancellationToken); lock (mappedChannels) - unmappedTextChannels = unmappedTextChannels + { + unmappedTextChannels = allAccessibleTextChannels .Where(x => !mappedChannels.Contains(x.ID.Value)) .ToList(); + var remapRequired = unmappedTextChannels.Any() + || mappedChannels.Any( + mappedChannel => !allAccessibleTextChannels.Any( + accessibleTextChannel => accessibleTextChannel.ID == new Snowflake(mappedChannel))); + + if (remapRequired) + EnqueueMessage(null); + } + // discord API confirmed weak boned: https://stackoverflow.com/a/52462336 if (unmappedTextChannels.Any()) { - Logger.LogTrace("Dispatching to {count} unmapped channels...", unmappedTextChannels.Count()); + Logger.LogDebug("Dispatching to {count} unmapped channels...", unmappedTextChannels.Count()); await Task.WhenAll( unmappedTextChannels.Select( x => SendToChannel(x.ID))); @@ -607,7 +605,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } currentUserId = currentUserResult.Entity.ID; - initialUserName = currentUserResult.Entity.Username; } finally { @@ -647,89 +644,133 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } /// - protected override async Task>> MapChannelsImpl(IEnumerable channels, CancellationToken cancellationToken) + protected override async Task>> MapChannelsImpl(IEnumerable channels, CancellationToken cancellationToken) { if (channels == null) throw new ArgumentNullException(nameof(channels)); var remapRequired = false; + var guildsClient = serviceProvider.GetRequiredService(); - async Task> GetModelChannelFromDBChannel(Models.ChatChannel channelFromDB) + async Task>> GetModelChannelFromDBChannel(Models.ChatChannel channelFromDB) { if (!channelFromDB.DiscordChannelId.HasValue) throw new InvalidOperationException("ChatChannel missing DiscordChannelId!"); var channelId = channelFromDB.DiscordChannelId.Value; - string connectionName; - string friendlyName; - if (channelId == 0) + var channelsClient = serviceProvider.GetRequiredService(); + var discordChannelResponse = await channelsClient.GetChannelAsync(new Snowflake(channelId), cancellationToken); + if (!discordChannelResponse.IsSuccess) { - connectionName = initialUserName; - friendlyName = "(Unmapped accessible channels)"; + Logger.LogWarning( + "Error retrieving discord channel {channelId}: {result}", + channelId, + discordChannelResponse.LogFormat()); + + remapRequired |= !(discordChannelResponse.Error is RestResultError restResultError + && (restResultError.Error?.Code == DiscordError.MissingAccess + || restResultError.Error?.Code == DiscordError.UnknownChannel)); + return null; } - else + + var channelType = discordChannelResponse.Entity.Type; + if (!SupportedGuildChannelTypes.Contains(channelType)) { - var channelsClient = serviceProvider.GetRequiredService(); - var discordChannelResponse = await channelsClient.GetChannelAsync(new Snowflake(channelId), cancellationToken); - if (!discordChannelResponse.IsSuccess) - { - Logger.LogWarning( - "Error retrieving discord channel {channelId}: {result}", - channelId, - discordChannelResponse.LogFormat()); - remapRequired = true; - return null; - } + Logger.LogWarning("Cound not map channel {channelId}! Incorrect type: {channelType}", channelId, discordChannelResponse.Entity.Type); + return null; + } - var channelType = discordChannelResponse.Entity.Type; - if (channelType != ChannelType.GuildText && channelType != ChannelType.GuildAnnouncement) - { - Logger.LogWarning("Cound not map channel {channelId}! Incorrect type: {channelType}", channelId, discordChannelResponse.Entity.Type); - return null; - } + var guildId = discordChannelResponse.Entity.GuildID.Value; - friendlyName = discordChannelResponse.Entity.Name.Value; - var guildId = discordChannelResponse.Entity.GuildID.Value; - - var guildsClient = serviceProvider.GetRequiredService(); - var guildsResponse = await guildsClient.GetGuildAsync( + var guildsResponse = await guildsClient.GetGuildAsync( + guildId, + false, + cancellationToken); + if (!guildsResponse.IsSuccess) + { + Logger.LogWarning( + "Error retrieving discord guild {guildID}: {result}", guildId, - false, - cancellationToken); - if (!guildsResponse.IsSuccess) - { - Logger.LogWarning( - "Error retrieving discord guild {guildID}: {result}", - guildId, - guildsResponse.LogFormat()); - remapRequired = true; - return null; - } - - connectionName = guildsResponse.Entity.Name; + guildsResponse.LogFormat()); + remapRequired |= true; + return null; } + var connectionName = guildsResponse.Entity.Name; + var channelModel = new ChannelRepresentation { RealId = channelId, IsAdminChannel = channelFromDB.IsAdminChannel == true, - ConnectionName = connectionName, - FriendlyName = friendlyName, + ConnectionName = guildsResponse.Entity.Name, + FriendlyName = discordChannelResponse.Entity.Name.Value, IsPrivateChannel = false, Tag = channelFromDB.Tag, EmbedsSupported = true, }; Logger.LogTrace("Mapped channel {realId}: {friendlyName}", channelModel.RealId, channelModel.FriendlyName); - return Tuple.Create(channelFromDB, channelModel); + return Tuple.Create>( + channelFromDB, + new List { channelModel }); } var tasks = channels - .Select(x => GetModelChannelFromDBChannel(x)) + .Where(x => x.DiscordChannelId != 0) + .Select(GetModelChannelFromDBChannel) .ToList(); await Task.WhenAll(tasks); + var channelIdZeroModel = channels.FirstOrDefault(x => x.DiscordChannelId == 0); + if (channelIdZeroModel != null) + { + Logger.LogInformation("Mapping ALL additional accessible text channels"); + var allAccessibleChannels = await GetAllAccessibleTextChannels(cancellationToken); + var unmappedTextChannels = allAccessibleChannels + .Where(x => !tasks.Any(task => task.Result != null && new Snowflake(task.Result.Item1.DiscordChannelId.Value) == x.ID)); + + async Task>> CreateMappingsForUnmappedChannels() + { + var unmappedTasks = + unmappedTextChannels.Select( + async unmappedTextChannel => + { + var fakeChannelModel = new Models.ChatChannel + { + DiscordChannelId = unmappedTextChannel.ID.Value, + IsAdminChannel = channelIdZeroModel.IsAdminChannel, + Tag = channelIdZeroModel.Tag, + }; + + var tuple = await GetModelChannelFromDBChannel(fakeChannelModel); + return tuple?.Item2.First(); + }) + .ToList(); + + // Add catch-all channel + unmappedTasks.Add(Task.FromResult( + new ChannelRepresentation + { + IsAdminChannel = channelIdZeroModel.IsAdminChannel.Value, + ConnectionName = "(Unknown Discord Guilds)", + EmbedsSupported = true, + FriendlyName = "(Unknown Discord Channels)", + RealId = 0, + Tag = channelIdZeroModel.Tag, + })); + + await Task.WhenAll(unmappedTasks); + return Tuple.Create>( + channelIdZeroModel, + unmappedTasks.Select(x => x.Result).Where(x => x != null).ToList()); + } + + var task = CreateMappingsForUnmappedChannels(); + await task; + tasks.Add(task); + } + var enumerator = tasks .Select(x => x.Result) .Where(x => x != null) @@ -738,13 +779,76 @@ namespace Tgstation.Server.Host.Components.Chat.Providers lock (mappedChannels) { mappedChannels.Clear(); - mappedChannels.AddRange(enumerator.Select(x => x.Item2.RealId)); + mappedChannels.AddRange(enumerator.SelectMany(x => x.Item2).Select(x => x.RealId)); } if (remapRequired) + { + Logger.LogWarning("Some channels failed to load with unknown errors. We will request that these be remapped, but it may result in communication spam. Please check prior logs and report an issue if this occurs."); EnqueueMessage(null); + } - return enumerator; + return new Dictionary>(enumerator.Select(x => new KeyValuePair>(x.Item1, x.Item2))); + } + + /// + /// Get all text s accessible to and supported by the bot. + /// + /// The for the operation. + /// A resulting in an of accessible and compatible s. + async Task> GetAllAccessibleTextChannels(CancellationToken cancellationToken) + { + var usersClient = serviceProvider.GetRequiredService(); + var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken); + if (!currentGuildsResponse.IsSuccess) + { + Logger.LogWarning( + "Error retrieving current discord guilds: {result}", + currentGuildsResponse.LogFormat()); + return Enumerable.Empty(); + } + + var guildsClient = serviceProvider.GetRequiredService(); + + async Task> GetGuildChannels(IPartialGuild guild) + { + var channelsTask = guildsClient.GetGuildChannelsAsync(guild.ID.Value, cancellationToken); + var threads = await guildsClient.ListActiveGuildThreadsAsync(guild.ID.Value, cancellationToken); + if (!threads.IsSuccess) + Logger.LogWarning( + "Error retrieving discord guild threads {guildId} ({guildName}): {result}", + guild.ID, + guild.Name, + threads.LogFormat()); + + var channels = await channelsTask; + if (!channels.IsSuccess) + Logger.LogWarning( + "Error retrieving discord guild channels {guildId} ({guildName}): {result}", + guild.ID, + guild.Name, + channels.LogFormat()); + + if (!channels.IsSuccess && !threads.IsSuccess) + return Enumerable.Empty(); + + if (channels.IsSuccess && threads.IsSuccess) + return channels.Entity.Concat(threads.Entity.Threads ?? Enumerable.Empty()); + + return channels.Entity ?? threads.Entity?.Threads ?? Enumerable.Empty(); + } + + var guildsChannelsTasks = currentGuildsResponse.Entity + .Select(GetGuildChannels) + .ToList(); + + await Task.WhenAll(guildsChannelsTasks); + + var allAccessibleChannels = guildsChannelsTasks + .SelectMany(task => task.Result) + .Where(guildChannel => SupportedGuildChannelTypes.Contains(guildChannel.Type)); + + return allAccessibleChannels; } /// @@ -753,7 +857,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// The to convert. /// The parameter for sending a single . #pragma warning disable CA1502 - private Optional> ConvertEmbed(ChatEmbed embed) + Optional> ConvertEmbed(ChatEmbed embed) { if (embed == null) return default; diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs index ab2aaeb742..abf1eefe20 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs @@ -58,8 +58,8 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// The s to map. /// The for the operation. - /// A resulting in a of the 's s representing . - Task>> MapChannels(IEnumerable channels, CancellationToken cancellationToken); + /// A resulting in a of the 's s representing . + Task>> MapChannels(IEnumerable channels, CancellationToken cancellationToken); /// /// Send a message to the . diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs index 5d08910ed9..0fcb845771 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs @@ -267,7 +267,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } /// - protected override Task>> MapChannelsImpl( + protected override Task>> MapChannelsImpl( IEnumerable channels, CancellationToken cancellationToken) => Task.Factory.StartNew( @@ -300,37 +300,40 @@ namespace Tgstation.Server.Host.Components.Chat.Providers else client.RfcJoin(channelToJoin); - return (IReadOnlyCollection>)channels - .Select(dbChannel => - { - var channelName = dbChannel.GetIrcChannelName(); - ulong? id = null; - if (!channelIdMap.Any(y => + return new Dictionary>( + channels + .Select(dbChannel => { - if (y.Value != channelName) - return false; - id = y.Key; - return true; - })) - { - id = channelIdCounter++; - channelIdMap.Add(id.Value, channelName); - } - - return Tuple.Create( - dbChannel, - new ChannelRepresentation + var channelName = dbChannel.GetIrcChannelName(); + ulong? id = null; + if (!channelIdMap.Any(y => { - RealId = id.Value, - IsAdminChannel = dbChannel.IsAdminChannel == true, - ConnectionName = address, - FriendlyName = channelIdMap[id.Value], - IsPrivateChannel = false, - Tag = dbChannel.Tag, - EmbedsSupported = false, - }); - }) - .ToList(); + if (y.Value != channelName) + return false; + id = y.Key; + return true; + })) + { + id = channelIdCounter++; + channelIdMap.Add(id.Value, channelName); + } + + return new KeyValuePair>( + dbChannel, + new List + { + new ChannelRepresentation + { + RealId = id.Value, + IsAdminChannel = dbChannel.IsAdminChannel == true, + ConnectionName = address, + FriendlyName = channelIdMap[id.Value], + IsPrivateChannel = false, + Tag = dbChannel.Tag, + EmbedsSupported = false, + }, + }); + })); } }, cancellationToken, diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs index 0422d24e51..1e2bf22ff0 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs @@ -118,7 +118,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers public void InitialMappingComplete() => initialConnectionTcs.TrySetResult(); /// - public async Task>> MapChannels(IEnumerable channels, CancellationToken cancellationToken) + public async Task>> MapChannels(IEnumerable channels, CancellationToken cancellationToken) { try { @@ -198,15 +198,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// The s to map. /// The for the operation. - /// A resulting in a of the 's s representing . - protected abstract Task>> MapChannelsImpl( + /// A resulting in a of the 's s representing . + protected abstract Task>> MapChannelsImpl( IEnumerable channels, CancellationToken cancellationToken); /// /// Queues a for . /// - /// The to queue. + /// The to queue. A value of indicates the channel mappings a out of date. protected void EnqueueMessage(Message message) { if (message == null)