diff --git a/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs b/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs index a56464cb72..af798e4ae6 100644 --- a/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs +++ b/src/Tgstation.Server.Host/Components/Chat/ChatManager.cs @@ -761,7 +761,7 @@ namespace Tgstation.Server.Host.Components.Chat { messageTasks.Remove(undisposedMessageTaskKvp.Key); if (undisposedMessageTaskKvp.Value.IsCompleted) - (await undisposedMessageTaskKvp.Value.ConfigureAwait(false))?.Context?.Dispose(); + await undisposedMessageTaskKvp.Value.ConfigureAwait(false); } // add new ones @@ -786,7 +786,6 @@ namespace Tgstation.Server.Host.Components.Chat foreach (var completedMessageTaskKvp in messageTasks.Where(x => x.Value.IsCompleted).ToList()) { var message = await completedMessageTaskKvp.Value.ConfigureAwait(false); - using var messageContext = message?.Context; var messageNumber = Interlocked.Increment(ref messagesProcessed); using (LogContext.PushProperty("ChatMessage", messageNumber)) await ProcessMessage(completedMessageTaskKvp.Key, message, cancellationToken).ConfigureAwait(false); diff --git a/src/Tgstation.Server.Host/Components/Chat/Message.cs b/src/Tgstation.Server.Host/Components/Chat/Message.cs index 8f04d8b2ea..ba4befa0a9 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Message.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Message.cs @@ -1,6 +1,4 @@ -using System; - -namespace Tgstation.Server.Host.Components.Chat.Providers +namespace Tgstation.Server.Host.Components.Chat.Providers { /// /// Represents a message recieved by a . @@ -16,10 +14,5 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// The who sent the . /// public ChatUser User { get; set; } - - /// - /// The that should be d once the is processed. - /// - public IDisposable Context { get; set; } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordForwardingResponder.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordForwardingResponder.cs new file mode 100644 index 0000000000..899380df1b --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordForwardingResponder.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.Gateway.Responders; +using Remora.Results; + +namespace Tgstation.Server.Host.Components.Chat.Providers +{ + /// + /// An that forwards to another . + /// + sealed class DiscordForwardingResponder : IDiscordResponders + { + /// + /// The to forward the event to. + /// + readonly IDiscordResponders targetResponder; + + /// + /// Initializes a new instance of the class. + /// + /// The value of . + public DiscordForwardingResponder(IDiscordResponders targetResponder) + { + this.targetResponder = targetResponder ?? throw new ArgumentNullException(nameof(targetResponder)); + } + + /// + public Task RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct) => targetResponder.RespondAsync(gatewayEvent, ct); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs index f04f9ba1bb..d660ca3eba 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs @@ -1,12 +1,20 @@ using System; using System.Collections.Generic; +using System.Drawing; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Discord; -using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.API.Abstractions.Objects; +using Remora.Discord.API.Abstractions.Rest; +using Remora.Discord.API.Objects; +using Remora.Discord.Core; +using Remora.Discord.Gateway; +using Remora.Discord.Gateway.Extensions; +using Remora.Results; using Tgstation.Server.Api.Models; using Tgstation.Server.Host.Jobs; @@ -18,10 +26,10 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// for the Discord app. /// - sealed class DiscordProvider : Provider + sealed class DiscordProvider : Provider, IDiscordResponders { /// - public override bool Connected => client.ConnectionState != ConnectionState.Disconnected; + public override bool Connected => gatewayTask?.IsCompleted == false; /// public override string BotMention @@ -30,7 +38,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers { if (!Connected) throw new InvalidOperationException("Provider not connected"); - return NormalizeMentions(client.CurrentUser.Mention); + return NormalizeMentions($"<@{currentUserId}>"); } } @@ -40,20 +48,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers readonly IAssemblyInformationProvider assemblyInformationProvider; /// - /// The for the . + /// The containing Discord services. /// - readonly DiscordSocketClient client; + readonly ServiceProvider serviceProvider; /// - /// of mapped s. + /// of mapped channel s. /// readonly List mappedChannels; - /// - /// The Discord bot token. - /// - readonly string botToken; - /// /// to enable based mode. Will auto reply with a youtube link to a video that says "based on the hardware that's installed in it" to anyone saying 'based on what?' case-insensitive. /// @@ -64,6 +67,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// readonly DiscordDMOutputDisplayType outputDisplayType; + /// + /// The for the . + /// + CancellationTokenSource gatewayCts; + + /// + /// The representing the lifetime of the client. + /// + Task gatewayTask; + + /// + /// The bot's . + /// + Snowflake currentUserId; + /// /// Normalize a discord mention string. /// @@ -72,15 +90,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers static string NormalizeMentions(string fromDiscord) => fromDiscord.Replace("<@!", "<@", StringComparison.Ordinal); /// - /// Create a of s for a discord update embed. + /// Create a of s for a discord update embed. /// /// The of the deployment. /// The BYOND of the deployment. /// The repository GitHub owner, if any. /// The repository GitHub name, if any. /// if the local deployment commit was pushed to the remote repository. - /// A new of s to use. - static List BuildUpdateEmbedFields( + /// A new of s to use. + static List BuildUpdateEmbedFields( Models.RevisionInformation revisionInformation, Version byondVersion, string gitHubOwner, @@ -88,39 +106,32 @@ namespace Tgstation.Server.Host.Components.Chat.Providers bool localCommitPushed) { bool gitHub = gitHubOwner != null && gitHubRepo != null; - var fields = new List + var fields = new List { - new EmbedFieldBuilder - { - Name = "BYOND Version", - Value = $"{byondVersion.Major}.{byondVersion.Minor}{(byondVersion.Build > 0 ? $".{byondVersion.Build}" : String.Empty)}", - IsInline = true, - }, - new EmbedFieldBuilder - { - Name = "Local Commit", - Value = localCommitPushed && gitHub + new EmbedField( + "BYOND Version", + $"{byondVersion.Major}.{byondVersion.Minor}{(byondVersion.Build > 0 ? $".{byondVersion.Build}" : String.Empty)}", + true), + new EmbedField( + "Local Commit", + localCommitPushed && gitHub ? $"[{revisionInformation.CommitSha.Substring(0, 7)}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionInformation.CommitSha})" : revisionInformation.CommitSha.Substring(0, 7), - IsInline = true, - }, - new EmbedFieldBuilder - { - Name = "Branch Commit", - Value = gitHub + true), + new EmbedField( + "Branch Commit", + gitHub ? $"[{revisionInformation.OriginCommitSha.Substring(0, 7)}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionInformation.OriginCommitSha})" : revisionInformation.OriginCommitSha.Substring(0, 7), - IsInline = true, - }, + true), }; fields.AddRange((revisionInformation.ActiveTestMerges ?? Enumerable.Empty()) .Select(x => x.TestMerge) - .Select(x => new EmbedFieldBuilder - { - Name = $"#{x.Number}", - Value = $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.TargetCommitSha.Substring(0, 7)}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.TargetCommitSha}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}", - })); + .Select(x => new EmbedField( + $"#{x.Number}", + $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.TargetCommitSha.Substring(0, 7)}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.TargetCommitSha}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}", + false))); return fields; } @@ -141,25 +152,32 @@ namespace Tgstation.Server.Host.Components.Chat.Providers { this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider)); + mappedChannels = new List(); + var csb = new DiscordConnectionStringBuilder(chatBot.ConnectionString); - botToken = csb.BotToken; + var botToken = csb.BotToken; basedMeme = csb.BasedMeme; outputDisplayType = csb.DMOutputDisplay; - client = new DiscordSocketClient(); - client.MessageReceived += Client_MessageReceived; - mappedChannels = new List(); + serviceProvider = new ServiceCollection() + .AddDiscordGateway(serviceProvider => botToken) + .AddSingleton(serviceProvider => this) + .AddResponder() + .BuildServiceProvider(); } /// public override async ValueTask DisposeAsync() { await base.DisposeAsync().ConfigureAwait(false); - client.Dispose(); + await serviceProvider.DisposeAsync().ConfigureAwait(false); + + // this line is purely here to shutup CA2213 + gatewayCts?.Dispose(); } /// - public override Task> MapChannels(IEnumerable channels, CancellationToken cancellationToken) + public override async Task> MapChannels(IEnumerable channels, CancellationToken cancellationToken) { if (channels == null) throw new ArgumentNullException(nameof(channels)); @@ -167,10 +185,19 @@ namespace Tgstation.Server.Host.Components.Chat.Providers if (!Connected) { Logger.LogWarning("Cannot map channels, provider disconnected!"); - return Task.FromResult>(Array.Empty()); + return Array.Empty(); } - ChannelRepresentation GetModelChannelFromDBChannel(Api.Models.ChatChannel channelFromDB) + var usersClient = serviceProvider.GetRequiredService(); + var currentUserResponse = await usersClient.GetCurrentUserAsync(cancellationToken).ConfigureAwait(false); + + if (!currentUserResponse.IsSuccess) + { + Logger.LogWarning("Error retrieving current Discord user: {0}", currentUserResponse.Error.Message); + return Array.Empty(); + } + + async Task GetModelChannelFromDBChannel(Api.Models.ChatChannel channelFromDB) { if (!channelFromDB.DiscordChannelId.HasValue) throw new InvalidOperationException("ChatChannel missing DiscordChannelId!"); @@ -181,22 +208,44 @@ namespace Tgstation.Server.Host.Components.Chat.Providers string friendlyName; if (channelId == 0) { - connectionName = client.CurrentUser.Username; + connectionName = currentUserResponse.Entity.Username; friendlyName = "(Unmapped accessible channels)"; discordChannelId = 0; } else { - var discordChannel = client.GetChannel(channelId); - if (!(discordChannel is ITextChannel textChannel)) + var channelsClient = serviceProvider.GetRequiredService(); + var discordChannelResponse = await channelsClient.GetChannelAsync(new Snowflake(channelId), cancellationToken); + if (!discordChannelResponse.IsSuccess) { - Logger.LogWarning("Cound not map channel {0}! Incorrect type: {1}", channelId, discordChannel?.GetType()); + Logger.LogWarning("Error retrieving discord channel {0}: {1}", channelId, discordChannelResponse.Error.Message); return null; } - discordChannelId = textChannel.Id; - connectionName = textChannel.Guild.Name; - friendlyName = textChannel.Name; + if (discordChannelResponse.Entity.Type != ChannelType.GuildText) + { + Logger.LogWarning("Cound not map channel {0}! Incorrect type: {1}", channelId, discordChannelResponse.Entity.Type); + return null; + } + + discordChannelId = discordChannelResponse.Entity.ID.Value; + friendlyName = discordChannelResponse.Entity.Name.Value; + + var guildsClient = serviceProvider.GetRequiredService(); + var guildsResponse = await guildsClient.GetGuildAsync( + discordChannelResponse.Entity.GuildID.Value, + false, + cancellationToken); + if (!guildsResponse.IsSuccess) + { + Logger.LogWarning( + "Error retrieving discord guild {0}: {1}", + discordChannelResponse.Entity.GuildID.Value, + discordChannelResponse.Error.Message); + return null; + } + + connectionName = guildsResponse.Entity.Name; } var channelModel = new ChannelRepresentation @@ -208,13 +257,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers IsPrivateChannel = false, Tag = channelFromDB.Tag, }; + Logger.LogTrace("Mapped channel {0}: {1}", channelModel.RealId, channelModel.FriendlyName); return channelModel; } - var enumerator = channels + var tasks = channels .Select(x => GetModelChannelFromDBChannel(x)) - .Where(x => x != null).ToList(); + .Where(x => x != null) + .ToList(); + + await Task.WhenAll(tasks); + + var enumerator = tasks + .Select(x => x.Result) + .ToList(); lock (mappedChannels) { @@ -222,57 +279,64 @@ namespace Tgstation.Server.Host.Components.Chat.Providers mappedChannels.AddRange(enumerator.Select(x => x.RealId)); } - return Task.FromResult>(enumerator); + return enumerator; } /// public override async Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken) { - var requestOptions = new RequestOptions + var channelsClient = serviceProvider.GetRequiredService(); + async Task SendToChannel(Snowflake channelId) { - CancelToken = cancellationToken, - Timeout = 10000, // prevent stupid long hold ups from this - }; + var result = await channelsClient.CreateMessageAsync( + channelId, + message, + ct: cancellationToken); - Task SendToChannel(IMessageChannel channel) => channel.SendMessageAsync( - message, - false, - null, - requestOptions); + if (!result.IsSuccess) + Logger.LogWarning( + "Failed to send to channel {0}: {1}", + channelId, + result.Error.Message); + } try { if (channelId == 0) { - var unmappedTextChannels = client - .Guilds - .SelectMany(x => x.TextChannels); + var usersClient = serviceProvider.GetRequiredService(); + var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken).ConfigureAwait(false); + if (!currentGuildsResponse.IsSuccess) + { + Logger.LogWarning( + "Error retrieving current discord guilds: {0}", + currentGuildsResponse.Error.Message); + return; + } + + var unmappedTextChannels = currentGuildsResponse + .Entity + .SelectMany(x => x.Channels.Value); lock (mappedChannels) - unmappedTextChannels = unmappedTextChannels.Where(x => !mappedChannels.Contains(x.Id)); + unmappedTextChannels = unmappedTextChannels + .Where(x => !mappedChannels.Contains(x.ID.Value)) + .ToList(); // discord API confirmed weak boned: https://stackoverflow.com/a/52462336 - var channelCount = 0UL; - var tasks = unmappedTextChannels - .Select(x => - { - ++channelCount; - return SendToChannel(x); - }); - - if (channelCount > 0) + if (unmappedTextChannels.Any()) { - Logger.LogTrace("Dispatched to {0} unmapped channels...", channelCount); - await Task.WhenAll(tasks).ConfigureAwait(false); + Logger.LogTrace("Dispatching to {0} unmapped channels...", unmappedTextChannels.Count()); + await Task.WhenAll( + unmappedTextChannels.Select( + x => SendToChannel(x.ID))) + .ConfigureAwait(false); } return; } - if (!(client.GetChannel(channelId) is IMessageChannel channel)) - return; - - await SendToChannel(channel).ConfigureAwait(false); + await SendToChannel(new Snowflake(channelId)).ConfigureAwait(false); } catch (Exception e) { @@ -283,6 +347,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers } /// +#pragma warning disable CA1506 public override async Task> SendUpdateMessage( Models.RevisionInformation revisionInformation, Version byondVersion, @@ -296,51 +361,53 @@ namespace Tgstation.Server.Host.Components.Chat.Providers localCommitPushed |= revisionInformation.CommitSha == revisionInformation.OriginCommitSha; var fields = BuildUpdateEmbedFields(revisionInformation, byondVersion, gitHubOwner, gitHubRepo, localCommitPushed); - var builder = new EmbedBuilder + var embed = new Embed { - Author = new EmbedAuthorBuilder + Author = new EmbedAuthor { Name = assemblyInformationProvider.VersionPrefix, Url = "https://github.com/tgstation/tgstation-server", IconUrl = "https://avatars0.githubusercontent.com/u/1363778?s=280&v=4", }, - Color = Color.Gold, + Colour = Color.FromArgb(0xF1, 0xC4, 0x0F), Description = "TGS has begun deploying active repository code to production.", Fields = fields, Title = "Code Deployment", - Footer = new EmbedFooterBuilder - { - Text = $"In progress...{(estimatedCompletionTime.HasValue ? " ETA" : String.Empty)}", - }, - Timestamp = estimatedCompletionTime, + Footer = new EmbedFooter( + $"In progress...{(estimatedCompletionTime.HasValue ? " ETA" : String.Empty)}"), + Timestamp = estimatedCompletionTime ?? default, }; Logger.LogTrace("Attempting to post deploy embed to channel {0}...", channelId); - if (!(client.GetChannel(channelId) is IMessageChannel channel)) - { - Logger.LogTrace("Channel ID {0} does not exist or is not an IMessageChannel!", channelId); - return (errorMessage, dreamMakerOutput) => Task.CompletedTask; - } + var channelsClient = serviceProvider.GetRequiredService(); - var message = await channel.SendMessageAsync( + var messageResponse = await channelsClient.CreateMessageAsync( + new Snowflake(channelId), "DM: Deployment in Progress...", - false, - builder.Build(), - new RequestOptions - { - CancelToken = cancellationToken, - }) + embeds: new List { embed }, + ct: cancellationToken) .ConfigureAwait(false); + if (!messageResponse.IsSuccess) + Logger.LogWarning("Failed to post deploy embed to channel {0}: {1}", channelId, messageResponse.Error.Message); + return async (errorMessage, dreamMakerOutput) => { var completionString = errorMessage == null ? "Succeeded" : "Failed"; - builder.Footer.Text = completionString; - builder.Color = errorMessage == null ? Color.Green : Color.Red; - builder.Timestamp = DateTimeOffset.UtcNow; - builder.Description = errorMessage == null + + embed = new Embed + { + Author = embed.Author, + Colour = errorMessage == null ? Color.Green : Color.Red, + Description = errorMessage == null ? "The deployment completed successfully and will be available at the next server reboot." - : "The deployment failed."; + : "The deployment failed.", + Fields = fields, + Title = embed.Title, + Footer = new EmbedFooter( + completionString), + Timestamp = DateTimeOffset.UtcNow, + }; var showDMOutput = outputDisplayType switch { @@ -352,83 +419,188 @@ namespace Tgstation.Server.Host.Components.Chat.Providers if (dreamMakerOutput != null) { - showDMOutput = showDMOutput && dreamMakerOutput.Length < EmbedFieldBuilder.MaxFieldValueLength - (6 + Environment.NewLine.Length); + // https://github.com/discord-net/Discord.Net/blob/8349cd7e1eb92e9a3baff68082c30a7b43e8e9b7/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs#L431 + const int MaxFieldValueLength = 1024; + showDMOutput = showDMOutput && dreamMakerOutput.Length < MaxFieldValueLength - (6 + Environment.NewLine.Length); if (showDMOutput) - builder.AddField(new EmbedFieldBuilder - { - Name = "DreamMaker Output", - Value = $"```{Environment.NewLine}{dreamMakerOutput}{Environment.NewLine}```", - }); + fields.Add(new EmbedField( + "DreamMaker Output", + $"```{Environment.NewLine}{dreamMakerOutput}{Environment.NewLine}```", + false)); } if (errorMessage != null) - builder.AddField(new EmbedFieldBuilder - { - Name = "Error Message", - Value = errorMessage, - }); + fields.Add(new EmbedField( + "Error Message", + errorMessage, + false)); var updatedMessage = $"DM: Deployment {completionString}!"; - try + + async Task CreateUpdatedMessage() { - await message.ModifyAsync( - props => - { - props.Content = updatedMessage; - props.Embed = builder.Build(); - }) + var createUpdatedMessageResponse = await channelsClient.CreateMessageAsync( + new Snowflake(channelId), + updatedMessage, + embeds: new List { embed }, + ct: cancellationToken) .ConfigureAwait(false); + + if (!createUpdatedMessageResponse.IsSuccess) + Logger.LogWarning( + "Creating updated deploy embed failed! Error: {0}", + createUpdatedMessageResponse.Error.Message); } - catch (Exception ex) + + if (!messageResponse.IsSuccess) + await CreateUpdatedMessage(); + else { - Logger.LogWarning(ex, "Updating deploy embed {0} failed, attempting new post!", message.Id); - try + var editResponse = await channelsClient.EditMessageAsync( + new Snowflake(channelId), + messageResponse.Entity.ID, + updatedMessage, + embeds: new List { embed }, + ct: cancellationToken) + .ConfigureAwait(false); + + if (!editResponse.IsSuccess) { - await channel.SendMessageAsync( - updatedMessage, - false, - builder.Build()) - .ConfigureAwait(false); - } - catch (Exception ex2) - { - Logger.LogWarning(ex2, "Posting completion deploy embed failed!"); + Logger.LogWarning( + "Updating deploy embed {0} failed, attempting new post! Error: {1}", + messageResponse.Entity.ID, + editResponse.Error.Message); + await CreateUpdatedMessage(); } } }; } + #pragma warning restore CA1506 + + /// + public async Task RespondAsync(IMessageCreate messageCreateEvent, CancellationToken cancellationToken) + { + if ((messageCreateEvent.Type != MessageType.Default + && messageCreateEvent.Type != MessageType.InlineReply) + || messageCreateEvent.Author.ID == currentUserId) + return Result.FromSuccess(); + + if (basedMeme && messageCreateEvent.Content.Equals("Based on what?", StringComparison.OrdinalIgnoreCase)) + { + // DCT: None available + await SendMessage( + messageCreateEvent.ChannelID.Value, + "https://youtu.be/LrNu-SuFF_o", + default) + .ConfigureAwait(false); + return Result.FromSuccess(); + } + + var channelsClient = serviceProvider.GetRequiredService(); + var channelResponse = await channelsClient.GetChannelAsync(messageCreateEvent.ChannelID, cancellationToken).ConfigureAwait(false); + if (!channelResponse.IsSuccess) + { + Logger.LogWarning( + "Failed to get channel {0} in response to message {1}!", + messageCreateEvent.ChannelID, + messageCreateEvent.ID); + + // we'll handle the errors ourselves + return Result.FromSuccess(); + } + + var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM; + var shouldNotAnswer = !pm; + if (shouldNotAnswer) + lock (mappedChannels) + shouldNotAnswer = !mappedChannels.Contains(messageCreateEvent.ChannelID.Value); + + var content = NormalizeMentions(messageCreateEvent.Content); + var mentionedUs = messageCreateEvent.Mentions.Any(x => x.ID == currentUserId) + || (!shouldNotAnswer && content.Split(' ').First().Equals(ChatManager.CommonMention, StringComparison.OrdinalIgnoreCase)); + + if (shouldNotAnswer) + { + if (mentionedUs) + Logger.LogTrace( + "Ignoring mention from {0} ({1}) by {2} ({3}). Channel not mapped!", + messageCreateEvent.ChannelID, + channelResponse.Entity.Name, + messageCreateEvent.Author.ID, + messageCreateEvent.Author.Username); + + return Result.FromSuccess(); + } + + string guildName = "UNKNOWN"; + if (!pm) + { + var guildsClient = serviceProvider.GetRequiredService(); + var messageGuildResponse = await guildsClient.GetGuildAsync(messageCreateEvent.GuildID.Value, false, cancellationToken).ConfigureAwait(false); + if (messageGuildResponse.IsSuccess) + guildName = messageGuildResponse.Entity.Name; + else + Logger.LogWarning( + "Failed to get channel {0} in response to message {1}!", + messageCreateEvent.ChannelID, + messageCreateEvent.ID); + } + + var result = new Message + { + Content = content, + User = new ChatUser + { + RealId = messageCreateEvent.Author.ID.Value, + Channel = new ChannelRepresentation + { + RealId = messageCreateEvent.ChannelID.Value, + IsPrivateChannel = pm, + ConnectionName = pm ? messageCreateEvent.Author.Username : guildName, + FriendlyName = channelResponse.Entity.Name.Value, + + // isAdmin and Tag populated by manager + }, + FriendlyName = messageCreateEvent.Author.Username, + Mention = NormalizeMentions($"<@{messageCreateEvent.Author.ID}>"), + }, + }; + + EnqueueMessage(result); + return Result.FromSuccess(); + } /// protected override async Task Connect(CancellationToken cancellationToken) { try { - await client.LoginAsync(TokenType.Bot, botToken, true).ConfigureAwait(false); + if (gatewayCts != null) + throw new InvalidOperationException("Discord gateway still active!"); - Logger.LogTrace("Logged in."); - cancellationToken.ThrowIfCancellationRequested(); + gatewayCts = new CancellationTokenSource(); - var channelsAvailable = new TaskCompletionSource(); - Task ReadyCallback() + var gatewayCancellationToken = gatewayCts.Token; + var gatewayClient = serviceProvider.GetRequiredService(); + + // reconnects keep happening until we stop or it faults, our auto-reconnector will handle the latter + gatewayTask = gatewayClient.RunAsync(gatewayCancellationToken); + + var userClient = serviceProvider.GetRequiredService(); + + using var localCombinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, gatewayCancellationToken); + var currentUserResult = await userClient.GetCurrentUserAsync(localCombinedCts.Token).ConfigureAwait(false); + if (!currentUserResult.IsSuccess) { - channelsAvailable.TrySetResult(null); - return Task.CompletedTask; + Logger.LogWarning("Unable to retrieve current user: {0}", currentUserResult.Error.Message); + + // will handle cleanup + await DisconnectImpl(cancellationToken).ConfigureAwait(false); + + throw new JobException(ErrorCode.ChatCannotConnectProvider); } - client.Ready += ReadyCallback; - try - { - await client.StartAsync().ConfigureAwait(false); - - Logger.LogTrace("Started."); - - using (cancellationToken.Register(() => channelsAvailable.SetCanceled())) - await channelsAvailable.Task.ConfigureAwait(false); - } - finally - { - client.Ready -= ReadyCallback; - } + currentUserId = currentUserResult.Entity.ID; } catch (OperationCanceledException) { @@ -443,153 +615,17 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// protected override async Task DisconnectImpl(CancellationToken cancellationToken) { - try - { - cancellationToken.ThrowIfCancellationRequested(); - var disconnectTcs = new TaskCompletionSource(); - Task DisconnectCallback(Exception exception) - { - if (exception != null) - Logger.LogTrace(exception, "Error stopping discord client!"); + if (gatewayCts == null) + throw new InvalidOperationException("Discord gateway is not active!"); - disconnectTcs.TrySetResult(null); - return Task.CompletedTask; - } + gatewayCts.Cancel(); + var gatewayResult = await gatewayTask.ConfigureAwait(false); + gatewayTask = null; + if (!gatewayResult.IsSuccess) + Logger.LogWarning("Gateway issue: {0}", gatewayResult.Error.Message); - try - { - client.Disconnected += DisconnectCallback; - - await client.StopAsync().ConfigureAwait(false); - - Logger.LogTrace("Waiting for disconnect callback..."); - using (cancellationToken.Register(() => disconnectTcs.SetCanceled())) - await disconnectTcs.Task.ConfigureAwait(false); - - // https://github.com/discord-net/Discord.Net/blob/8afef8245cfd1f8b56956dd4b4577ed3c6904be5/src/Discord.Net.WebSocket/ConnectionManager.cs#L176 - // State isn't set to disconnected until AFTER the callback fires - // Meaning if we check this.Connected right now it will still return true - // Yielding here will prevent this - await Task.Yield(); - - Logger.LogTrace("Stop async complete."); - } - finally - { - client.Disconnected -= DisconnectCallback; - } - - cancellationToken.ThrowIfCancellationRequested(); - var logoutTcs = new TaskCompletionSource(); - Task LogoutCallback() - { - logoutTcs.TrySetResult(null); - return Task.CompletedTask; - } - - client.LoggedOut += LogoutCallback; - try - { - await client.LogoutAsync().ConfigureAwait(false); - - Logger.LogTrace("Waiting for logout callback..."); - using (cancellationToken.Register(() => logoutTcs.SetCanceled())) - await logoutTcs.Task.ConfigureAwait(false); - } - finally - { - client.LoggedOut -= LogoutCallback; - } - - Logger.LogDebug("Disconnected!"); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception e) - { - Logger.LogWarning(e, "Error disconnecting from discord!"); - } - } - - /// - /// Handle a message recieved from Discord. - /// - /// The . - /// A representing the running operation. - async Task Client_MessageReceived(SocketMessage e) - { - if (e.Author.Id == client.CurrentUser.Id) - return; - - IDisposable typingState = null; - void StartTyping() => typingState = e.Channel.EnterTypingState(); - try - { - if (basedMeme && e.Content.Equals("Based on what?", StringComparison.OrdinalIgnoreCase)) - { - StartTyping(); - - // DCT: None available - await SendMessage( - e.Channel.Id, - "https://youtu.be/LrNu-SuFF_o", - default) - .ConfigureAwait(false); - return; - } - - var pm = e.Channel is IPrivateChannel; - var shouldNotAnswer = !pm; - if (shouldNotAnswer) - lock (mappedChannels) - shouldNotAnswer = !mappedChannels.Contains(e.Channel.Id); - - var content = NormalizeMentions(e.Content); - var mentionedUs = e.MentionedUsers.Any(x => x.Id == client.CurrentUser.Id) - || (!shouldNotAnswer && content.Split(' ').First().Equals(ChatManager.CommonMention, StringComparison.OrdinalIgnoreCase)); - if (mentionedUs) - StartTyping(); - - if (shouldNotAnswer) - { - if (mentionedUs) - { - Logger.LogTrace("Ignoring mention from {0} ({1}) by {2} ({3}). Channel not mapped!", e.Channel.Id, e.Channel.Name, e.Author.Id, e.Author.Username); - } - - return; - } - - var result = new Message - { - Content = content, - User = new ChatUser - { - RealId = e.Author.Id, - Channel = new ChannelRepresentation - { - RealId = e.Channel.Id, - IsPrivateChannel = pm, - ConnectionName = pm ? e.Author.Username : (e.Channel as ITextChannel)?.Guild.Name ?? "UNKNOWN", - FriendlyName = e.Channel.Name, - - // isAdmin and Tag populated by manager - }, - FriendlyName = e.Author.Username, - Mention = NormalizeMentions(e.Author.Mention), - }, - Context = typingState, - }; - - EnqueueMessage(result); - typingState = null; - } - finally - { - typingState?.Dispose(); - } + gatewayCts.Dispose(); + gatewayCts = null; } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IDiscordResponders.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IDiscordResponders.cs new file mode 100644 index 0000000000..602f134da6 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IDiscordResponders.cs @@ -0,0 +1,12 @@ +using Remora.Discord.API.Abstractions.Gateway.Events; +using Remora.Discord.Gateway.Responders; + +namespace Tgstation.Server.Host.Components.Chat.Providers +{ + /// + /// Combined interface for the types used by TGS. + /// + interface IDiscordResponders : IResponder + { + } +} diff --git a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs index d66cd2ec68..6f2d11c3f1 100644 --- a/src/Tgstation.Server.Host/IO/DefaultIOManager.cs +++ b/src/Tgstation.Server.Host/IO/DefaultIOManager.cs @@ -369,18 +369,12 @@ namespace Tgstation.Server.Host.IO await CreateDirectory(dest, cancellationToken).ConfigureAwait(false); // save on createdir calls var tasks = new List(); - - await dir.EnumerateFiles() - .ToAsyncEnumerable() - .ForEachAsync( - fileInfo => - { - if (ignore != null && ignore.Contains(fileInfo.Name)) - return; - tasks.Add(CopyFile(fileInfo.FullName, Path.Combine(dest, fileInfo.Name), cancellationToken)); - }, - cancellationToken) - .ConfigureAwait(false); + foreach (var fileInfo in dir.EnumerateFiles()) + { + if (ignore != null && ignore.Contains(fileInfo.Name)) + return; + tasks.Add(CopyFile(fileInfo.FullName, Path.Combine(dest, fileInfo.Name), cancellationToken)); + } await Task.WhenAll(tasks).ConfigureAwait(false); } diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index db593981c1..579b98fa6c 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -66,7 +66,6 @@ - @@ -88,6 +87,7 @@ +