Merge pull request #1935 from craftxbox/dev

Refactor IrcProvider just a little
This commit is contained in:
Jordan Dominion
2024-10-04 23:21:47 -04:00
committed by GitHub
8 changed files with 194 additions and 93 deletions
+2
View File
@@ -251,6 +251,8 @@ Create an `appsettings.Production.yml` file next to `appsettings.yml`. This will
- `FileLogging:LogLevel`: Can be one of `Trace`, `Debug`, `Information`, `Warning`, `Error`, or `Critical`. Restricts what is put into the log files. Currently `Debug` is reccommended for help with error reporting.
- `FileLogging:ProviderNetworkDebug`: Boolean controlling whether or not Chat bot providers should log their raw network traffic. Currently only applies to IrcProvider.
- `Kestrel:Endpoints:Http:Url`: The URL (i.e. interface and ports) your application should listen on. General use case should be `http://localhost:<port>` for restricted local connections. See the Remote Access section for configuring public access to the World Wide Web. This doesn't need to be changed using the docker setup and should be mapped with the `-p` option instead
- `Database:DatabaseType`: Can be one of `SqlServer`, `MariaDB`, `MySql`, `PostgresSql`, or `Sqlite`.
+1 -1
View File
@@ -4,7 +4,7 @@
<Import Project="WebpanelVersion.props" />
<PropertyGroup>
<TgsCoreVersion>6.10.0</TgsCoreVersion>
<TgsConfigVersion>5.2.0</TgsConfigVersion>
<TgsConfigVersion>5.3.0</TgsConfigVersion>
<TgsApiVersion>10.10.0</TgsApiVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>16.0.0</TgsApiLibraryVersion>
@@ -88,6 +88,7 @@ namespace Tgstation.Server.Api.Models
case IrcPasswordType.NickServ:
case IrcPasswordType.Sasl:
case IrcPasswordType.Server:
case IrcPasswordType.Oper:
PasswordType = passwordType;
break;
default:
@@ -19,5 +19,10 @@
/// Use NickServ authentication.
/// </summary>
NickServ,
/// <summary>
/// Use OPER authentication.
/// </summary>
Oper,
}
}
@@ -13,6 +13,7 @@ using Newtonsoft.Json;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Components.Interop;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Extensions;
using Tgstation.Server.Host.IO;
using Tgstation.Server.Host.Jobs;
@@ -42,11 +43,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <inheritdoc />
public override string BotMention => client.Nickname;
/// <summary>
/// The <see cref="IrcFeatures"/> client.
/// </summary>
readonly IrcFeatures client;
/// <summary>
/// Address of the server to connect to.
/// </summary>
@@ -57,6 +53,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// </summary>
readonly ushort port;
/// <summary>
/// Wether or not this IRC client is to use ssl.
/// </summary>
readonly bool ssl;
/// <summary>
/// IRC nickname.
/// </summary>
@@ -82,6 +83,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// </summary>
readonly Dictionary<ulong, string> queryChannelIdMap;
/// <summary>
/// The <see cref="IAssemblyInformationProvider"/> obtained from constructor, used for the CTCP version string.
/// </summary>
readonly IAssemblyInformationProvider assemblyInfo;
/// <summary>
/// The <see cref="FileLoggingConfiguration"/> for the <see cref="IrcProvider"/>.
/// </summary>
readonly FileLoggingConfiguration loggingConfiguration;
/// <summary>
/// The <see cref="IrcFeatures"/> client.
/// </summary>
IrcFeatures client;
/// <summary>
/// The <see cref="ValueTask"/> used for <see cref="IrcConnection.Listen(bool)"/>.
/// </summary>
@@ -92,11 +108,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// </summary>
ulong channelIdCounter;
/// <summary>
/// If we are disconnecting.
/// </summary>
bool disconnecting;
/// <summary>
/// Initializes a new instance of the <see cref="IrcProvider"/> class.
/// </summary>
@@ -105,15 +116,18 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="Provider"/>.</param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> to get the <see cref="IAssemblyInformationProvider.VersionString"/> from.</param>
/// <param name="chatBot">The <see cref="Models.ChatBot"/> for the <see cref="Provider"/>.</param>
/// <param name="loggingConfiguration">The <see cref="FileLoggingConfiguration"/> for the <see cref="Provider"/>.</param>
public IrcProvider(
IJobManager jobManager,
IAsyncDelayer asyncDelayer,
ILogger<IrcProvider> logger,
IAssemblyInformationProvider assemblyInformationProvider,
Models.ChatBot chatBot)
Models.ChatBot chatBot,
FileLoggingConfiguration loggingConfiguration)
: base(jobManager, asyncDelayer, logger, chatBot)
{
ArgumentNullException.ThrowIfNull(assemblyInformationProvider);
ArgumentNullException.ThrowIfNull(loggingConfiguration);
var builder = chatBot.CreateConnectionStringBuilder();
if (builder == null || !builder.Valid || builder is not IrcConnectionStringBuilder ircBuilder)
@@ -121,33 +135,16 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
address = ircBuilder.Address!;
port = ircBuilder.Port!.Value;
ssl = ircBuilder.UseSsl!.Value;
nickname = ircBuilder.Nickname!;
password = ircBuilder.Password!;
passwordType = ircBuilder.PasswordType;
client = new IrcFeatures
{
SupportNonRfc = true,
CtcpUserInfo = "You are going to play. And I am going to watch. And everything will be just fine...",
AutoRejoin = true,
AutoRejoinOnKick = true,
AutoRelogin = true,
AutoRetry = false,
AutoReconnect = false,
ActiveChannelSyncing = true,
AutoNickHandling = true,
CtcpVersion = assemblyInformationProvider.VersionString,
UseSsl = ircBuilder.UseSsl!.Value,
};
if (ircBuilder.UseSsl.Value)
client.ValidateServerCertificate = true; // dunno if it defaults to that or what
assemblyInfo = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
this.loggingConfiguration = loggingConfiguration ?? throw new ArgumentNullException(nameof(loggingConfiguration));
client.OnChannelMessage += Client_OnChannelMessage;
client.OnQueryMessage += Client_OnQueryMessage;
/*client.OnReadLine += (sender, e) => Logger.LogTrace("READ: {line}", e.Line);
client.OnWriteLine += (sender, e) => Logger.LogTrace("WRITE: {line}", e.Line);*/
client = InstantiateClient();
channelIdMap = new Dictionary<ulong, string?>();
queryChannelIdMap = new Dictionary<ulong, string>();
@@ -369,12 +366,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <inheritdoc />
protected override async ValueTask Connect(CancellationToken cancellationToken)
{
disconnecting = false;
cancellationToken.ThrowIfCancellationRequested();
try
{
await Task.Factory.StartNew(
() => client.Connect(address, port),
() =>
{
client = InstantiateClient();
client.Connect(address, port);
},
cancellationToken,
DefaultIOManager.BlockingTaskCreationOptions,
TaskScheduler.Current)
@@ -382,70 +382,53 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
cancellationToken.ThrowIfCancellationRequested();
listenTask = Task.Factory.StartNew(
() =>
{
Logger.LogTrace("Starting blocking listen...");
try
{
client.Listen();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "IRC Main Listen Exception!");
}
Logger.LogTrace("Exiting listening task...");
},
cancellationToken,
DefaultIOManager.BlockingTaskCreationOptions,
TaskScheduler.Current);
Logger.LogTrace("Authenticating ({passwordType})...", passwordType);
switch (passwordType)
{
case IrcPasswordType.Server:
client.Login(nickname, nickname, 0, nickname, password);
client.RfcPass(password);
await Login(client, nickname, cancellationToken);
break;
case IrcPasswordType.NickServ:
client.Login(nickname, nickname, 0, nickname);
await Login(client, nickname, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
client.SendMessage(SendType.Message, "NickServ", String.Format(CultureInfo.InvariantCulture, "IDENTIFY {0}", password));
break;
case IrcPasswordType.Sasl:
await SaslAuthenticate(cancellationToken);
break;
case IrcPasswordType.Oper:
await Login(client, nickname, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
client.RfcOper(nickname, password, Priority.Critical);
break;
case null:
client.Login(nickname, nickname, 0, nickname);
await Login(client, nickname, cancellationToken);
break;
default:
throw new InvalidOperationException($"Invalid IrcPasswordType: {passwordType.Value}");
}
cancellationToken.ThrowIfCancellationRequested();
Logger.LogTrace("Processing initial messages...");
await NonBlockingListen(cancellationToken);
var nickCheckCompleteTcs = new TaskCompletionSource();
using (cancellationToken.Register(() => nickCheckCompleteTcs.TrySetCanceled(cancellationToken)))
{
listenTask = Task.Factory.StartNew(
async () =>
{
Logger.LogTrace("Entering nick check loop");
while (!disconnecting && client.IsConnected && client.Nickname != nickname)
{
client.ListenOnce(true);
if (disconnecting || !client.IsConnected)
break;
await NonBlockingListen(cancellationToken);
// ensure we have the correct nick
if (client.GetIrcUser(nickname) == null)
client.RfcNick(nickname);
}
nickCheckCompleteTcs.TrySetResult();
Logger.LogTrace("Starting blocking listen...");
try
{
client.Listen();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "IRC Main Listen Exception!");
}
Logger.LogTrace("Exiting listening task...");
},
cancellationToken,
DefaultIOManager.BlockingTaskCreationOptions,
TaskScheduler.Current);
await nickCheckCompleteTcs.Task;
}
Logger.LogTrace("Connection established!");
}
@@ -487,6 +470,44 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
}
}
/// <summary>
/// Register the client on the network.
/// </summary>
/// <param name="client">IRC client.</param>
/// <param name="nickname">Nickname.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns><see cref="Task"/> that resolves when registration has been completed. </returns>
/// <exception cref="TimeoutException">If the IRC server fails to respond.</exception>
async ValueTask Login(IrcFeatures client, string nickname, CancellationToken cancellationToken)
{
var promise = new TaskCompletionSource<object>();
void Callback(object? sender, EventArgs e)
{
Logger.LogTrace("IRC Registered.");
promise.TrySetResult(e);
}
client.OnRegistered += Callback;
client.Login(nickname, nickname, 0, nickname);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(30));
try
{
await promise.Task.WaitAsync(cts.Token);
client.OnRegistered -= Callback;
}
catch (OperationCanceledException)
{
if (client.IsConnected)
client.Disconnect();
throw new JobException("Timed out waiting for IRC Registration");
}
}
/// <summary>
/// Handle an IRC message.
/// </summary>
@@ -667,8 +688,6 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
Logger.LogTrace("Hard disconnect");
disconnecting = true;
// This call blocks permanently randomly sometimes
// Frankly I don't give a shit
var disconnectTask = Task.Factory.StartNew(
@@ -693,5 +712,48 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
listenTask ?? Task.CompletedTask),
AsyncDelayer.Delay(TimeSpan.FromSeconds(5), cancellationToken));
}
/// <summary>
/// Creates a new instance of the IRC client.
/// Reusing the same client after a disconnection seems to cause issues.
/// </summary>
/// <returns>The <see cref="IrcFeatures"/> client to use.</returns>
IrcFeatures InstantiateClient()
{
var newClient = new IrcFeatures
{
SupportNonRfc = true,
CtcpUserInfo = "You are going to play. And I am going to watch. And everything will be just fine...",
AutoRejoin = true,
AutoRejoinOnKick = true,
AutoRelogin = false,
AutoRetry = false,
AutoReconnect = false,
ActiveChannelSyncing = true,
AutoNickHandling = true,
CtcpVersion = assemblyInfo.VersionString,
UseSsl = ssl,
EnableUTF8Recode = true,
};
if (ssl)
newClient.ValidateServerCertificate = true; // dunno if it defaults to that or what
newClient.OnChannelMessage += Client_OnChannelMessage;
newClient.OnQueryMessage += Client_OnQueryMessage;
if (loggingConfiguration.ProviderNetworkDebug)
{
newClient.OnReadLine += (sender, e) => Logger.LogTrace("READ: {line}", e.Line);
newClient.OnWriteLine += (sender, e) => Logger.LogTrace("WRITE: {line}", e.Line);
}
newClient.OnError += (sender, e) =>
{
Logger.LogError("IRC ERROR: {error}", e.ErrorMessage);
newClient.Disconnect();
};
return newClient;
}
}
}
@@ -40,6 +40,11 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// </summary>
readonly GeneralConfiguration generalConfiguration;
/// <summary>
/// The <see cref="FileLoggingConfiguration"/> for the <see cref="ProviderFactory"/>.
/// </summary>
readonly FileLoggingConfiguration loggingConfiguration;
/// <summary>
/// Initializes a new instance of the <see cref="ProviderFactory"/> class.
/// </summary>
@@ -48,18 +53,21 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
/// <param name="asyncDelayer">The value of <see cref="asyncDelayer"/>.</param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/>.</param>
/// <param name="generalConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="generalConfiguration"/>.</param>
/// <param name="loggingConfigurationOptions">The <see cref="IOptions{TOptions}"/> containing the value of <see cref="loggingConfiguration"/>.</param>
public ProviderFactory(
IJobManager jobManager,
IAssemblyInformationProvider assemblyInformationProvider,
IAsyncDelayer asyncDelayer,
ILoggerFactory loggerFactory,
IOptions<GeneralConfiguration> generalConfigurationOptions)
IOptions<GeneralConfiguration> generalConfigurationOptions,
IOptions<FileLoggingConfiguration> loggingConfigurationOptions)
{
this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager));
this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
this.asyncDelayer = asyncDelayer ?? throw new ArgumentNullException(nameof(asyncDelayer));
this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
generalConfiguration = generalConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
loggingConfiguration = loggingConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(loggingConfigurationOptions));
}
/// <inheritdoc />
@@ -73,7 +81,8 @@ namespace Tgstation.Server.Host.Components.Chat.Providers
asyncDelayer,
loggerFactory.CreateLogger<IrcProvider>(),
assemblyInformationProvider,
settings),
settings,
loggingConfiguration),
ChatProvider.Discord => new DiscordProvider(
jobManager,
asyncDelayer,
@@ -29,6 +29,11 @@ namespace Tgstation.Server.Host.Configuration
/// </summary>
public bool Disable { get; set; }
/// <summary>
/// If Chat Providers should log their network traffic. Normally disabled because it is too noisy.
/// </summary>
public bool ProviderNetworkDebug { get; set; }
/// <summary>
/// The minimum <see cref="Microsoft.Extensions.Logging.LogLevel"/> to display in logs.
/// </summary>
@@ -9,6 +9,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Tgstation.Server.Api.Models;
using Tgstation.Server.Host.Configuration;
using Tgstation.Server.Host.Jobs;
using Tgstation.Server.Host.Models;
using Tgstation.Server.Host.System;
@@ -22,15 +23,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests
[TestMethod]
public async Task TestConstructionAndDisposal()
{
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(null, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(null, null, null, null, null, null));
var mockJobManager = new Mock<IJobManager>();
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(mockJobManager.Object, null, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(mockJobManager.Object, null, null, null, null, null));
var mockAsyncDelayer = new Mock<IAsyncDelayer>();
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, null, null, null));
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, null, null, null, null));
var mockLogger = new Mock<ILogger<IrcProvider>>();
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, null, null));
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, null, null, null));
var mockAss = new Mock<IAssemblyInformationProvider>();
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, null));
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, null, null));
var mockBot = new ChatBot
{
@@ -38,8 +39,10 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests
Instance = new Models.Instance(),
Provider = ChatProvider.Irc
};
Assert.ThrowsException<ArgumentNullException>(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, null));
Assert.ThrowsException<InvalidOperationException>(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot));
var mockLogConf = new FileLoggingConfiguration();
Assert.ThrowsException<InvalidOperationException>(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, mockLogConf));
mockBot.ConnectionString = new IrcConnectionStringBuilder
{
@@ -49,7 +52,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests
Port = 6667
}.ToString();
await new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot).DisposeAsync();
await new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot, mockLogConf).DisposeAsync();
}
static ValueTask InvokeConnect(IProvider provider, CancellationToken cancellationToken = default) => (ValueTask)provider.GetType().GetMethod("Connect", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(provider, new object[] { cancellationToken });
@@ -78,16 +81,30 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests
.Setup(x => x.WaitForJobCompletion(It.IsNotNull<Job>(), It.IsAny<User>(), It.IsAny<CancellationToken>(), It.IsAny<CancellationToken>()))
.Returns(ValueTask.FromResult<bool?>(true));
var mockJobManager = mockSetup.Object;
await using var provider = new IrcProvider(mockJobManager, new AsyncDelayer(), loggerFactory.CreateLogger<IrcProvider>(), Mock.Of<IAssemblyInformationProvider>(), new ChatBot
var chatBot = new ChatBot
{
ConnectionString = actualToken,
Provider = ChatProvider.Irc,
Instance = new Models.Instance(),
});
};
await using var provider = new IrcProvider(mockJobManager, new AsyncDelayer(), loggerFactory.CreateLogger<IrcProvider>(), Mock.Of<IAssemblyInformationProvider>(), chatBot, new FileLoggingConfiguration());
Assert.IsFalse(provider.Connected);
await InvokeConnect(provider);
Assert.IsTrue(provider.Connected);
await Task.Delay(2000); // IRC servers do not like it when you connect and disconnect in rapid succession
await provider.Disconnect(default);
Assert.IsFalse(provider.Connected);
await Task.Delay(2000); // same as above
await InvokeConnect(provider);
await Task.Delay(2000); // make sure it stays connected after a reconnect attempt
Assert.IsTrue(provider.Connected);
await provider.Disconnect(default);
Assert.IsFalse(provider.Connected);
}