Hack automatic reconnection into the chat system

This commit is contained in:
Jordan Brown
2020-03-24 17:08:44 -04:00
parent 55b4834126
commit 26c5632289
9 changed files with 252 additions and 88 deletions
@@ -1,5 +1,6 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Tgstation.Server.Api.Models.Internal
{
@@ -28,6 +29,7 @@ namespace Tgstation.Server.Api.Models.Internal
/// The time interval in minutes the chat bot attempts to reconnect if <see cref="Enabled"/> and disconnected.
/// </summary>
[Required]
[NotMapped]
public uint? ReconnectionInterval { get; set; }
/// <summary>
@@ -82,9 +82,14 @@ namespace Tgstation.Server.Host.Components.Chat
readonly CancellationTokenSource handlerCts;
/// <summary>
/// The initial <see cref="Models.ChatBot"/> for the <see cref="Chat"/>
/// The active <see cref="Models.ChatBot"/> for the <see cref="Chat"/>
/// </summary>
readonly List<Models.ChatBot> initialChatBots;
readonly List<Models.ChatBot> activeChatBots;
/// <summary>
/// Used for various lock statements throughout this <see langword="class"/>.
/// </summary>
readonly object synchronizationLock;
/// <summary>
/// The <see cref="ICustomCommandHandler"/> for the <see cref="ChangeChannels(long, IEnumerable{Api.Models.ChatChannel}, CancellationToken)"/>
@@ -121,7 +126,7 @@ namespace Tgstation.Server.Host.Components.Chat
/// <param name="asyncDelayer">The value of <see cref="asyncDelayer"/></param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
/// <param name="initialChatBots">The <see cref="IEnumerable{T}"/> used to populate <see cref="initialChatBots"/></param>
/// <param name="initialChatBots">The <see cref="IEnumerable{T}"/> used to populate <see cref="activeChatBots"/></param>
public Chat(IProviderFactory providerFactory, IIOManager ioManager, ICommandFactory commandFactory, IServerControl serverControl, IAsyncDelayer asyncDelayer, ILoggerFactory loggerFactory, ILogger<Chat> logger, IEnumerable<Models.ChatBot> initialChatBots)
{
this.providerFactory = providerFactory ?? throw new ArgumentNullException(nameof(providerFactory));
@@ -132,10 +137,12 @@ namespace Tgstation.Server.Host.Components.Chat
this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.initialChatBots = initialChatBots?.ToList() ?? throw new ArgumentNullException(nameof(initialChatBots));
activeChatBots = initialChatBots?.ToList() ?? throw new ArgumentNullException(nameof(initialChatBots));
restartRegistration = serverControl.RegisterForRestart(this);
synchronizationLock = new object();
builtinCommands = new Dictionary<string, ICommand>();
providers = new Dictionary<long, IProvider>();
mappedChannels = new Dictionary<ulong, ChannelMapping>();
@@ -160,7 +167,7 @@ namespace Tgstation.Server.Host.Components.Chat
/// <param name="connectionId">The <see cref="ChatBot.Id"/> of the <see cref="IProvider"/> to delete</param>
/// <param name="updateTrackings">If <see cref="trackingContexts"/> should be update</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IProvider"/> being removed if it exists, <see langword="false"/> otherwise</returns>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IProvider"/> being removed if it exists, <see langword="null"/> otherwise</returns>
async Task<IProvider> RemoveProvider(long connectionId, bool updateTrackings, CancellationToken cancellationToken)
{
IProvider provider;
@@ -188,11 +195,31 @@ namespace Tgstation.Server.Host.Components.Chat
/// Processes a <paramref name="message"/>
/// </summary>
/// <param name="provider">The <see cref="IProvider"/> who recevied <paramref name="message"/></param>
/// <param name="message">The <see cref="Message"/> to process</param>
/// <param name="message">The <see cref="Message"/> to process. If <see langword="null"/>, this indicates the provider reconnected.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
#pragma warning disable CA1502
async Task ProcessMessage(IProvider provider, Message message, CancellationToken cancellationToken)
#pragma warning restore CA1502
{
// provider reconnected, remap channels.
if (message == null)
{
IEnumerable<Api.Models.ChatChannel> channelsToMap;
lock (activeChatBots)
channelsToMap = activeChatBots.FirstOrDefault()?.Channels;
if (channelsToMap?.Any() ?? false)
{
long providerId;
lock (providers)
providerId = providers.Where(x => x.Value == provider).Select(x => x.Key).First();
await ChangeChannels(providerId, channelsToMap, cancellationToken).ConfigureAwait(false);
}
return;
}
// map the channel if it's private and we haven't seen it
lock (providers)
{
@@ -347,7 +374,7 @@ namespace Tgstation.Server.Host.Components.Chat
// add new ones
Task updatedTask;
lock (this)
lock (synchronizationLock)
updatedTask = connectionsUpdated.Task;
lock (providers)
foreach (var I in providers)
@@ -367,9 +394,7 @@ namespace Tgstation.Server.Host.Components.Chat
foreach (var I in messageTasks.Where(x => x.Value.IsCompleted).ToList())
{
var message = await I.Value.ConfigureAwait(false);
await ProcessMessage(I.Key, message, cancellationToken).ConfigureAwait(false);
messageTasks.Remove(I.Key);
}
}
@@ -393,8 +418,23 @@ namespace Tgstation.Server.Host.Components.Chat
if (provider == null)
return;
var results = await provider.MapChannels(newChannels, cancellationToken).ConfigureAwait(false);
if (results == null) // aborted
return;
lock (activeChatBots)
{
var botToUpdate = activeChatBots.FirstOrDefault(bot => bot.Id == connectionId);
if (botToUpdate != null)
botToUpdate.Channels = newChannels
.Select(apiModel => new Models.ChatChannel
{
DiscordChannelId = apiModel.DiscordChannelId,
IrcChannel = apiModel.IrcChannel,
IsAdminChannel = apiModel.IsAdminChannel,
IsUpdatesChannel = apiModel.IsUpdatesChannel,
IsWatchdogChannel = apiModel.IsWatchdogChannel,
Tag = apiModel.Tag
})
.ToList();
}
var mappings = Enumerable.Zip(newChannels, results, (x, y) => new ChannelMapping
{
IsWatchdogChannel = x.IsWatchdogChannel == true,
@@ -405,13 +445,13 @@ namespace Tgstation.Server.Host.Components.Chat
});
ulong baseId;
lock (this)
lock (synchronizationLock)
{
baseId = channelIdCounter;
channelIdCounter += (ulong)results.Count;
}
Task task;
Task trackingContextUpdateTask;
lock (mappedChannels)
{
lock (providers)
@@ -425,10 +465,10 @@ namespace Tgstation.Server.Host.Components.Chat
}
lock (trackingContexts)
task = Task.WhenAll(trackingContexts.Select(x => x.SetChannels(mappedChannels.Select(y => y.Value.Channel), cancellationToken)));
trackingContextUpdateTask = Task.WhenAll(trackingContexts.Select(x => x.SetChannels(mappedChannels.Select(y => y.Value.Channel), cancellationToken)));
}
await task.ConfigureAwait(false);
await trackingContextUpdateTask.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -478,7 +518,7 @@ namespace Tgstation.Server.Host.Components.Chat
{
if (newSettings.Enabled.Value)
await provider.Connect(cancellationToken).ConfigureAwait(false);
lock (this)
lock (synchronizationLock)
{
// same thread shennanigans
var oldOne = connectionsUpdated;
@@ -486,6 +526,31 @@ namespace Tgstation.Server.Host.Components.Chat
oldOne.SetResult(null);
}
}
Task reconnectionUpdateTask = Task.CompletedTask;
lock (activeChatBots)
{
var originalChatBot = activeChatBots.FirstOrDefault(bot => bot.Id == newSettings.Id);
if (originalChatBot != null)
{
if (originalChatBot.ReconnectionInterval != newSettings.ReconnectionInterval)
reconnectionUpdateTask = provider.SetReconnectInterval(newSettings.ReconnectionInterval.Value);
activeChatBots.Remove(originalChatBot);
}
activeChatBots.Add(new Models.ChatBot
{
Id = newSettings.Id,
ConnectionString = newSettings.ConnectionString,
Enabled = newSettings.Enabled,
Name = newSettings.Name,
ReconnectionInterval = newSettings.ReconnectionInterval,
Provider = newSettings.Provider
});
}
await reconnectionUpdateTask.ConfigureAwait(false);
}
/// <inheritdoc />
@@ -535,9 +600,9 @@ namespace Tgstation.Server.Host.Components.Chat
{
foreach (var I in commandFactory.GenerateCommands())
builtinCommands.Add(I.Name.ToUpperInvariant(), I);
await Task.WhenAll(initialChatBots.Select(x => ChangeSettings(x, cancellationToken))).ConfigureAwait(false);
await Task.WhenAll(activeChatBots.Select(x => ChangeSettings(x, cancellationToken))).ConfigureAwait(false);
await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Connect(cancellationToken))).ConfigureAwait(false);
await Task.WhenAll(initialChatBots.Select(x => ChangeChannels(x.Id, x.Channels, cancellationToken))).ConfigureAwait(false);
await Task.WhenAll(activeChatBots.Select(x => ChangeChannels(x.Id, x.Channels, cancellationToken))).ConfigureAwait(false);
chatHandler = MonitorMessages(handlerCts.Token);
started = true;
}
@@ -579,13 +644,6 @@ namespace Tgstation.Server.Host.Components.Chat
return context;
}
/// <inheritdoc />
public bool Connected(long connectionId)
{
lock (providers)
return providers.TryGetValue(connectionId, out var provider) && provider.Connected;
}
/// <inheritdoc />
public void RegisterCommandHandler(ICustomCommandHandler customCommandHandler)
{
@@ -12,13 +12,6 @@ namespace Tgstation.Server.Host.Components.Chat
/// </summary>
public interface IChat : IHostedService, IDisposable
{
/// <summary>
/// If a given set of <see cref="ChatBot"/> is connected
/// </summary>
/// <param name="connectionId">The <see cref="ChatBot.Id"/> of the connection</param>
/// <returns><see langword="true"/> if it is connected, <see langword="false"/> otherwise</returns>
bool Connected(long connectionId);
/// <summary>
/// Registers a <paramref name="customCommandHandler"/> to use
/// </summary>
@@ -54,7 +47,7 @@ namespace Tgstation.Server.Host.Components.Chat
/// Send a chat <paramref name="message"/> to a given set of <paramref name="channelIds"/>
/// </summary>
/// <param name="message">The message being sent</param>
/// <param name="channelIds">The <see cref="Models.ChatChannel.Id"/>s of the <see cref="Host.Models.ChatChannel"/>s to send to</param>
/// <param name="channelIds">The <see cref="Models.ChatChannel.Id"/>s of the <see cref="Models.ChatChannel"/>s to send to</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task SendMessage(string message, IEnumerable<ulong> channelIds, CancellationToken cancellationToken);
@@ -50,10 +50,10 @@ namespace Tgstation.Server.Host.Components.Chat
{
case ChatProvider.Irc:
var ircBuilder = (IrcConnectionStringBuilder)builder;
return new IrcProvider(application, asyncDelayer, loggerFactory.CreateLogger<IrcProvider>(), ircBuilder.Address, ircBuilder.Port.Value, ircBuilder.Nickname, ircBuilder.Password, ircBuilder.PasswordType, ircBuilder.UseSsl.Value);
return new IrcProvider(application, asyncDelayer, loggerFactory.CreateLogger<IrcProvider>(), ircBuilder.Address, ircBuilder.Port.Value, ircBuilder.Nickname, ircBuilder.Password, ircBuilder.PasswordType, settings.ReconnectionInterval.Value, ircBuilder.UseSsl.Value);
case ChatProvider.Discord:
var discordBuilder = (DiscordConnectionStringBuilder)builder;
return new DiscordProvider(loggerFactory.CreateLogger<DiscordProvider>(), discordBuilder.BotToken);
return new DiscordProvider(loggerFactory.CreateLogger<DiscordProvider>(), discordBuilder.BotToken, settings.ReconnectionInterval.Value);
default:
throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid ChatProvider: {0}", settings.Provider));
}
@@ -29,11 +29,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
}
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="DiscordProvider"/>
/// </summary>
readonly ILogger<DiscordProvider> logger;
/// <summary>
/// The <see cref="DiscordSocketClient"/> for the <see cref="DiscordProvider"/>
/// </summary>
@@ -59,23 +54,24 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <summary>
/// Construct a <see cref="DiscordProvider"/>
/// </summary>
/// <param name="logger">The value of <see cref="logger"/></param>
/// <param name="logger">The value of <see cref="Logger"/></param>
/// <param name="botToken">The value of <see cref="botToken"/></param>
public DiscordProvider(ILogger<DiscordProvider> logger, string botToken)
/// <param name="reconnectInterval">The initial reconnect interval in minutes.</param>
public DiscordProvider(ILogger<DiscordProvider> logger, string botToken, uint reconnectInterval)
: base(logger, reconnectInterval)
{
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.botToken = botToken ?? throw new ArgumentNullException(nameof(botToken));
client = new DiscordSocketClient();
client.MessageReceived += Client_MessageReceived;
mappedChannels = new List<ulong>();
logger.LogTrace("Created.");
}
/// <inheritdoc />
public override void Dispose()
{
logger.LogTrace("Disposed.");
client.Dispose();
base.Dispose();
}
/// <summary>
@@ -95,7 +91,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
var mentionedUs = e.MentionedUsers.Any(x => x.Id == client.CurrentUser.Id);
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);
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);
await SendMessage(e.Channel.Id, "I do not respond to this channel!", default).ConfigureAwait(false);
}
@@ -127,10 +123,10 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <inheritdoc />
public override async Task<bool> Connect(CancellationToken cancellationToken)
{
logger.LogTrace("Connecting...");
Logger.LogTrace("Connecting...");
if (Connected)
{
logger.LogTrace("Already connected not doing connection attempt!");
Logger.LogTrace("Already connected not doing connection attempt!");
return true;
}
@@ -138,12 +134,12 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
{
await client.LoginAsync(TokenType.Bot, botToken, true).ConfigureAwait(false);
logger.LogTrace("Logged in.");
Logger.LogTrace("Logged in.");
cancellationToken.ThrowIfCancellationRequested();
await client.StartAsync().ConfigureAwait(false);
logger.LogTrace("Started.");
Logger.LogTrace("Started.");
var channelsAvailable = new TaskCompletionSource<object>();
client.Ready += () =>
@@ -153,7 +149,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
};
using (cancellationToken.Register(() => channelsAvailable.SetCanceled()))
await channelsAvailable.Task.ConfigureAwait(false);
logger.LogDebug("Connection established!");
Logger.LogDebug("Connection established!");
}
catch (OperationCanceledException)
{
@@ -161,7 +157,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
catch (Exception e)
{
logger.LogWarning("Error connecting to Discord: {0}", e);
Logger.LogWarning("Error connecting to Discord: {0}", e);
return false;
}
@@ -171,20 +167,20 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <inheritdoc />
public override async Task Disconnect(CancellationToken cancellationToken)
{
logger.LogTrace("Disconnecting...");
Logger.LogTrace("Disconnecting...");
if (!Connected)
{
logger.LogTrace("Already disconnected not doing disconnection attempt!");
Logger.LogTrace("Already disconnected not doing disconnection attempt!");
return;
}
try
{
await client.StopAsync().ConfigureAwait(false);
logger.LogTrace("Stopped.");
Logger.LogTrace("Stopped.");
cancellationToken.ThrowIfCancellationRequested();
await client.LogoutAsync().ConfigureAwait(false);
logger.LogDebug("Disconnected!");
Logger.LogDebug("Disconnected!");
}
catch (OperationCanceledException)
{
@@ -192,18 +188,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
catch (Exception e)
{
logger.LogWarning("Error disconnecting from discord: {0}", e);
Logger.LogWarning("Error disconnecting from discord: {0}", e);
}
}
/// <inheritdoc />
public override Task<IReadOnlyList<Channel>> MapChannels(IEnumerable<ChatChannel> channels, CancellationToken cancellationToken)
public override Task<IReadOnlyCollection<Channel>> MapChannels(IEnumerable<ChatChannel> channels, CancellationToken cancellationToken)
{
if (channels == null)
throw new ArgumentNullException(nameof(channels));
if (!Connected)
throw new InvalidOperationException("Provider not connected!");
{
Logger.LogWarning("Cannot map channels, provider disconnected!");
return Task.FromResult<IReadOnlyCollection<Channel>>(Array.Empty<Channel>());
}
Channel GetModelChannelFromDBChannel(ChatChannel channelFromDB)
{
@@ -223,11 +222,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
IsPrivateChannel = false,
Tag = channelFromDB.Tag
};
logger.LogTrace("Mapped channel {0}: {1}", channelModel.RealId, channelModel.FriendlyName);
Logger.LogTrace("Mapped channel {0}: {1}", channelModel.RealId, channelModel.FriendlyName);
return channelModel;
}
logger.LogWarning("Cound not map channel {0}! Incorrect type: {1}", channelId, discordChannel.GetType());
Logger.LogWarning("Cound not map channel {0}! Incorrect type: {1}", channelId, discordChannel.GetType());
return null;
}
@@ -239,7 +238,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
mappedChannels.AddRange(enumerator.Select(x => x.RealId));
}
return Task.FromResult<IReadOnlyList<Channel>>(enumerator);
return Task.FromResult<IReadOnlyCollection<Channel>>(enumerator);
}
/// <inheritdoc />
@@ -259,7 +258,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
catch (Exception e)
{
logger.LogWarning("Error sending discord message: {0}", e);
Logger.LogWarning("Error sending discord message: {0}", e);
}
}
}
@@ -24,7 +24,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// Get a <see cref="Task{TResult}"/> resulting in the next <see cref="Message"/> the <see cref="IProvider"/> recieves or <see langword="null"/> on a disconnect
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the next available <see cref="Message"/></returns>
/// <returns>A <see cref="Task{TResult}"/> resulting in the next available <see cref="Message"/> or <see langword="null"/> if the <see cref="IProvider"/> needed to reconnect.</returns>
/// <remarks>Note that private messages will come in the form of <see cref="Channel"/>s not returned in <see cref="MapChannels(IEnumerable{Api.Models.ChatChannel}, CancellationToken)"/></remarks>
Task<Message> NextMessage(CancellationToken cancellationToken);
@@ -47,8 +47,8 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// </summary>
/// <param name="channels">The <see cref="Api.Models.ChatChannel"/>s to map</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyList{T}"/> of the <see cref="Channel"/>s representing <paramref name="channels"/></returns>
Task<IReadOnlyList<Channel>> MapChannels(IEnumerable<Api.Models.ChatChannel> channels, CancellationToken cancellationToken);
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyCollection{T}"/> of the <see cref="Channel"/>s representing <paramref name="channels"/></returns>
Task<IReadOnlyCollection<Channel>> MapChannels(IEnumerable<Api.Models.ChatChannel> channels, CancellationToken cancellationToken);
/// <summary>
/// Send a message to the <see cref="IProvider"/>
@@ -58,5 +58,12 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken);
/// <summary>
/// Set the interval at which the provider tries to reconnect.
/// </summary>
/// <param name="reconnectInterval">The reconnection interval in minutes.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
Task SetReconnectInterval(uint reconnectInterval);
}
}
@@ -30,11 +30,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// </summary>
readonly IAsyncDelayer asyncDelayer;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="IrcProvider"/>
/// </summary>
readonly ILogger<IrcProvider> logger;
/// <summary>
/// The <see cref="IrcFeatures"/> client
/// </summary>
@@ -101,13 +96,24 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <param name="nickname">The value of <see cref="nickname"/></param>
/// <param name="password">The value of <see cref="password"/></param>
/// <param name="passwordType">The value of <see cref="passwordType"/></param>
/// <param name="reconnectInterval">The initial reconnect interval in minutes.</param>
/// <param name="useSsl">If <see cref="IrcConnection.UseSsl"/> should be used</param>
public IrcProvider(IApplication application, IAsyncDelayer asyncDelayer, ILogger<IrcProvider> logger, string address, ushort port, string nickname, string password, IrcPasswordType? passwordType, bool useSsl)
public IrcProvider(
IApplication application,
IAsyncDelayer asyncDelayer,
ILogger<IrcProvider> logger,
string address,
ushort port,
string nickname,
string password,
IrcPasswordType? passwordType,
uint reconnectInterval,
bool useSsl)
: base(logger, reconnectInterval)
{
if (application == null)
throw new ArgumentNullException(nameof(application));
this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.address = address ?? throw new ArgumentNullException(nameof(address));
this.port = port;
@@ -157,6 +163,8 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
disconnecting = true;
client.Disconnect(); // just closes the socket
}
base.Dispose();
}
/// <summary>
@@ -328,7 +336,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
catch (Exception e)
{
logger.LogWarning("Unable to connect to IRC: {0}", e);
Logger.LogWarning("Unable to connect to IRC: {0}", e);
return false;
}
@@ -350,7 +358,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
catch (Exception e)
{
logger.LogWarning("Error quitting IRC: {0}", e);
Logger.LogWarning("Error quitting IRC: {0}", e);
}
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current).ConfigureAwait(false);
Dispose();
@@ -362,12 +370,12 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
catch (Exception e)
{
logger.LogWarning("Error disconnecting from IRC! Exception: {0}", e);
Logger.LogWarning("Error disconnecting from IRC! Exception: {0}", e);
}
}
/// <inheritdoc />
public override Task<IReadOnlyList<Channel>> MapChannels(IEnumerable<ChatChannel> channels, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
public override Task<IReadOnlyCollection<Channel>> MapChannels(IEnumerable<ChatChannel> channels, CancellationToken cancellationToken) => Task.Factory.StartNew(() =>
{
if (channels.Any(x => x.IrcChannel == null))
throw new InvalidOperationException("ChatChannel missing IrcChannel!");
@@ -386,7 +394,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
foreach (var I in hs)
client.RfcJoin(I);
return (IReadOnlyList<Channel>)channels.Select(x =>
return (IReadOnlyCollection<Channel>)channels.Select(x =>
{
ulong? id = null;
if (!channelIdMap.Any(y =>
@@ -432,7 +440,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
catch (Exception e)
{
logger.LogWarning("Unable to send to channel: {0}", e);
Logger.LogWarning("Unable to send to channel: {0}", e);
}
}, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current);
}
@@ -1,4 +1,6 @@
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tgstation.Server.Api.Models;
@@ -8,6 +10,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <inheritdoc />
abstract class Provider : IProvider
{
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="Provider"/>.
/// </summary>
protected ILogger Logger { get; }
/// <summary>
/// <see cref="Queue{T}"/> of received <see cref="Message"/>s
/// </summary>
@@ -18,13 +25,30 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// </summary>
TaskCompletionSource<object> nextMessage;
/// <summary>
/// The auto reconnect <see cref="Task"/>
/// </summary>
Task reconnectTask;
/// <summary>
/// <see cref="CancellationTokenSource"/> for <see cref="reconnectTask"/>
/// </summary>
CancellationTokenSource reconnectCts;
/// <summary>
/// Construct a <see cref="Provider"/>
/// </summary>
protected Provider()
/// <param name="logger">The value of <see cref="Logger"/>.</param>
/// <param name="reconnectInterval">The initial reconnection interval.</param>
protected Provider(ILogger logger, uint reconnectInterval)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
messageQueue = new Queue<Message>();
nextMessage = new TaskCompletionSource<object>();
SetReconnectInterval(reconnectInterval).GetAwaiter().GetResult();
logger.LogTrace("Created.");
}
/// <inheritdoc />
@@ -47,7 +71,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
/// <inheritdoc />
public abstract void Dispose();
public virtual void Dispose()
{
StopReconnectionTimer().GetAwaiter().GetResult();
Logger.LogTrace("Disposed");
}
/// <inheritdoc />
public abstract Task<bool> Connect(CancellationToken cancellationToken);
@@ -56,7 +84,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
public abstract Task Disconnect(CancellationToken cancellationToken);
/// <inheritdoc />
public abstract Task<IReadOnlyList<Channel>> MapChannels(IEnumerable<ChatChannel> channels, CancellationToken cancellationToken);
public abstract Task<IReadOnlyCollection<Channel>> MapChannels(IEnumerable<ChatChannel> channels, CancellationToken cancellationToken);
/// <inheritdoc />
public async Task<Message> NextMessage(CancellationToken cancellationToken)
@@ -74,6 +102,74 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
}
/// <summary>
/// Stops and awaits the <see cref="reconnectTask"/>.
/// </summary>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
async Task StopReconnectionTimer()
{
reconnectCts?.Cancel();
reconnectCts?.Dispose();
if (reconnectTask != null)
{
await reconnectTask.ConfigureAwait(false);
reconnectTask = null;
}
}
/// <inheritdoc />
public async Task SetReconnectInterval(uint reconnectInterval)
{
if (reconnectInterval == 0)
throw new ArgumentOutOfRangeException(nameof(reconnectInterval), reconnectInterval, "Reconnect interval cannot be zero!");
await StopReconnectionTimer().ConfigureAwait(false);
reconnectCts = new CancellationTokenSource();
try
{
reconnectTask = ReconnectionLoop(reconnectInterval, reconnectCts.Token);
}
catch
{
reconnectCts.Dispose();
reconnectCts = null;
throw;
}
}
/// <summary>
/// Creates a <see cref="Task"/> that will attempt to reconnect the <see cref="Provider"/> every <paramref name="reconnectInterval"/> minutes.
/// </summary>
/// <param name="reconnectInterval">The amount of minutes to wait between reconnection attempts.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task"/> representing the running operation.</returns>
async Task ReconnectionLoop(uint reconnectInterval, CancellationToken cancellationToken)
{
do
{
try
{
await Task.Delay(TimeSpan.FromMinutes(reconnectInterval), cancellationToken).ConfigureAwait(false);
if (!Connected)
{
Logger.LogInformation("Attempting to reconnect provider...");
await Disconnect(cancellationToken).ConfigureAwait(false);
if (await Connect(cancellationToken).ConfigureAwait(false))
EnqueueMessage(null);
}
}
catch (OperationCanceledException)
{
break;
}
catch(Exception e)
{
Logger.LogError(e, "Error reconnecting!");
}
}
while (true);
}
/// <inheritdoc />
public abstract Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken);
}
@@ -21,18 +21,19 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests
[TestMethod]
public void TestConstructionAndDisposal()
{
Assert.ThrowsException<ArgumentNullException>(() => new DiscordProvider(null, null));
Assert.ThrowsException<ArgumentNullException>(() => new DiscordProvider(null, null, 1));
var mockLogger = new Mock<ILogger<DiscordProvider>>();
Assert.ThrowsException<ArgumentNullException>(() => new DiscordProvider(mockLogger.Object, null));
Assert.ThrowsException<ArgumentNullException>(() => new DiscordProvider(mockLogger.Object, null, 1));
var mockToken = "asdf";
new DiscordProvider(mockLogger.Object, mockToken).Dispose();
Assert.ThrowsException<ArgumentOutOfRangeException>(() => new DiscordProvider(mockLogger.Object, mockToken, 0));
new DiscordProvider(mockLogger.Object, mockToken, 1).Dispose();
}
[TestMethod]
public async Task TestConnectWithFakeTokenFails()
{
var mockLogger = new Mock<ILogger<DiscordProvider>>();
using (var provider = new DiscordProvider(mockLogger.Object, "asdf"))
using (var provider = new DiscordProvider(mockLogger.Object, "asdf", 1))
{
Assert.IsFalse(await provider.Connect(default).ConfigureAwait(false));
Assert.IsFalse(provider.Connected);
@@ -48,7 +49,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests
var mockLogger = new Mock<ILogger<DiscordProvider>>();
using (var provider = new DiscordProvider(mockLogger.Object, testToken1))
using (var provider = new DiscordProvider(mockLogger.Object, testToken1, 1))
{
Assert.IsFalse(provider.Connected);
await provider.Disconnect(default).ConfigureAwait(false);