Switch from Discord.Net to Remora.Discord

- Remove typing event support.
- Remove Message.Context as it was used only for that
- .ToAsyncEnumerable() seemed to have relied on a sub-library of Discord.Net. Removed it's usage in DefaultIOManager.
This commit is contained in:
Jordan Brown
2021-08-22 22:08:44 -04:00
parent b7eb97e554
commit aea9c8e7b0
7 changed files with 397 additions and 330 deletions
@@ -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);
@@ -1,6 +1,4 @@
using System;
namespace Tgstation.Server.Host.Components.Chat.Providers
namespace Tgstation.Server.Host.Components.Chat.Providers
{
/// <summary>
/// Represents a message recieved by a <see cref="IProvider"/>.
@@ -16,10 +14,5 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// The <see cref="ChatUser"/> who sent the <see cref="Message"/>.
/// </summary>
public ChatUser User { get; set; }
/// <summary>
/// The <see cref="IDisposable"/> that should be <see cref="IDisposable.Dispose"/>d once the <see cref="Message"/> is processed.
/// </summary>
public IDisposable Context { get; set; }
}
}
@@ -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
{
/// <summary>
/// An <see cref="IResponder{TGatewayEvent}"/> that forwards to another <see cref="targetResponder"/>.
/// </summary>
sealed class DiscordForwardingResponder : IDiscordResponders
{
/// <summary>
/// The <see cref="IResponder{TGatewayEvent}"/> to forward the event to.
/// </summary>
readonly IDiscordResponders targetResponder;
/// <summary>
/// Initializes a new instance of the <see cref="DiscordForwardingResponder"/> class.
/// </summary>
/// <param name="targetResponder">The value of <see cref="targetResponder"/>.</param>
public DiscordForwardingResponder(IDiscordResponders targetResponder)
{
this.targetResponder = targetResponder ?? throw new ArgumentNullException(nameof(targetResponder));
}
/// <inheritdoc />
public Task<Result> RespondAsync(IMessageCreate gatewayEvent, CancellationToken ct) => targetResponder.RespondAsync(gatewayEvent, ct);
}
}
@@ -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
/// <summary>
/// <see cref="IProvider"/> for the Discord app.
/// </summary>
sealed class DiscordProvider : Provider
sealed class DiscordProvider : Provider, IDiscordResponders
{
/// <inheritdoc />
public override bool Connected => client.ConnectionState != ConnectionState.Disconnected;
public override bool Connected => gatewayTask?.IsCompleted == false;
/// <inheritdoc />
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;
/// <summary>
/// The <see cref="DiscordSocketClient"/> for the <see cref="DiscordProvider"/>.
/// The <see cref="ServiceProvider"/> containing Discord services.
/// </summary>
readonly DiscordSocketClient client;
readonly ServiceProvider serviceProvider;
/// <summary>
/// <see cref="List{T}"/> of mapped <see cref="ITextChannel"/> <see cref="IEntity{TId}.Id"/>s.
/// <see cref="List{T}"/> of mapped channel <see cref="Snowflake"/>s.
/// </summary>
readonly List<ulong> mappedChannels;
/// <summary>
/// The Discord bot token.
/// </summary>
readonly string botToken;
/// <summary>
/// <see cref="bool"/> 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.
/// </summary>
@@ -64,6 +67,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// </summary>
readonly DiscordDMOutputDisplayType outputDisplayType;
/// <summary>
/// The <see cref="CancellationTokenSource"/> for the <see cref="gatewayTask"/>.
/// </summary>
CancellationTokenSource gatewayCts;
/// <summary>
/// The <see cref="Task"/> representing the lifetime of the client.
/// </summary>
Task<Result> gatewayTask;
/// <summary>
/// The bot's <see cref="Snowflake"/>.
/// </summary>
Snowflake currentUserId;
/// <summary>
/// Normalize a discord mention string.
/// </summary>
@@ -72,15 +90,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
static string NormalizeMentions(string fromDiscord) => fromDiscord.Replace("<@!", "<@", StringComparison.Ordinal);
/// <summary>
/// Create a <see cref="List{T}"/> of <see cref="EmbedFieldBuilder"/>s for a discord update embed.
/// Create a <see cref="List{T}"/> of <see cref="IEmbedField"/>s for a discord update embed.
/// </summary>
/// <param name="revisionInformation">The <see cref="RevisionInformation"/> of the deployment.</param>
/// <param name="byondVersion">The BYOND <see cref="Version"/> of the deployment.</param>
/// <param name="gitHubOwner">The repository GitHub owner, if any.</param>
/// <param name="gitHubRepo">The repository GitHub name, if any.</param>
/// <param name="localCommitPushed"><see langword="true"/> if the local deployment commit was pushed to the remote repository.</param>
/// <returns>A new <see cref="List{T}"/> of <see cref="EmbedFieldBuilder"/>s to use.</returns>
static List<EmbedFieldBuilder> BuildUpdateEmbedFields(
/// <returns>A new <see cref="List{T}"/> of <see cref="IEmbedField"/>s to use.</returns>
static List<IEmbedField> 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<EmbedFieldBuilder>
var fields = new List<IEmbedField>
{
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<RevInfoTestMerge>())
.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<ulong>();
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<ulong>();
serviceProvider = new ServiceCollection()
.AddDiscordGateway(serviceProvider => botToken)
.AddSingleton<IDiscordResponders>(serviceProvider => this)
.AddResponder<DiscordForwardingResponder>()
.BuildServiceProvider();
}
/// <inheritdoc />
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();
}
/// <inheritdoc />
public override Task<IReadOnlyCollection<ChannelRepresentation>> MapChannels(IEnumerable<Api.Models.ChatChannel> channels, CancellationToken cancellationToken)
public override async Task<IReadOnlyCollection<ChannelRepresentation>> MapChannels(IEnumerable<Api.Models.ChatChannel> 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<IReadOnlyCollection<ChannelRepresentation>>(Array.Empty<ChannelRepresentation>());
return Array.Empty<ChannelRepresentation>();
}
ChannelRepresentation GetModelChannelFromDBChannel(Api.Models.ChatChannel channelFromDB)
var usersClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
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<ChannelRepresentation>();
}
async Task<ChannelRepresentation> 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<IDiscordRestChannelAPI>();
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<IDiscordRestGuildAPI>();
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<IReadOnlyCollection<ChannelRepresentation>>(enumerator);
return enumerator;
}
/// <inheritdoc />
public override async Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken)
{
var requestOptions = new RequestOptions
var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
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<IDiscordRestUserAPI>();
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
}
/// <inheritdoc />
#pragma warning disable CA1506
public override async Task<Func<string, string, 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<IDiscordRestChannelAPI>();
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<IEmbed> { 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<IEmbed> { 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<IEmbed> { 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
/// <inheritdoc />
public async Task<Result> 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<IDiscordRestChannelAPI>();
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<IDiscordRestGuildAPI>();
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();
}
/// <inheritdoc />
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<object>();
Task ReadyCallback()
var gatewayCancellationToken = gatewayCts.Token;
var gatewayClient = serviceProvider.GetRequiredService<DiscordGatewayClient>();
// reconnects keep happening until we stop or it faults, our auto-reconnector will handle the latter
gatewayTask = gatewayClient.RunAsync(gatewayCancellationToken);
var userClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
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
/// <inheritdoc />
protected override async Task DisconnectImpl(CancellationToken cancellationToken)
{
try
{
cancellationToken.ThrowIfCancellationRequested();
var disconnectTcs = new TaskCompletionSource<object>();
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<object>();
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!");
}
}
/// <summary>
/// Handle a message recieved from Discord.
/// </summary>
/// <param name="e">The <see cref="SocketMessage"/>.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
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;
}
}
}
@@ -0,0 +1,12 @@
using Remora.Discord.API.Abstractions.Gateway.Events;
using Remora.Discord.Gateway.Responders;
namespace Tgstation.Server.Host.Components.Chat.Providers
{
/// <summary>
/// Combined interface for the <see cref="IResponder"/> types used by TGS.
/// </summary>
interface IDiscordResponders : IResponder<IMessageCreate>
{
}
}
@@ -369,18 +369,12 @@ namespace Tgstation.Server.Host.IO
await CreateDirectory(dest, cancellationToken).ConfigureAwait(false); // save on createdir calls
var tasks = new List<Task>();
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);
}
@@ -66,7 +66,6 @@
<PackageReference Include="Byond.TopicSender" Version="5.0.0" />
<PackageReference Include="Cyberboss.AspNetCore.AsyncInitializer" Version="1.2.0" />
<PackageReference Include="Cyberboss.SmartIrc4net.Standard" Version="0.4.6" />
<PackageReference Include="Discord.Net.WebSocket" Version="2.4.0" />
<PackageReference Include="Elastic.CommonSchema.Serilog" Version="1.5.3" />
<PackageReference Include="GitLabApiClient" Version="1.8.0" />
<PackageReference Include="LibGit2Sharp" Version="0.27.0-preview-0034" />
@@ -88,6 +87,7 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.11" />
<PackageReference Include="Octokit" Version="0.50.0" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="3.2.6" />
<PackageReference Include="Remora.Discord" Version="3.0.54" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.0" />