diff --git a/src/DMAPI/tgs.dm b/src/DMAPI/tgs.dm index c3daf1a380..628b17cac6 100644 --- a/src/DMAPI/tgs.dm +++ b/src/DMAPI/tgs.dm @@ -103,7 +103,7 @@ //represents a chat user /datum/tgs_chat_user - var/id //Internal user representation + var/id //Internal user representation, requires channel to be unique var/friendly_name //The user's public name var/mention //The text to use to ping this user in a message var/datum/tgs_chat_channel/channel //The /datum/tgs_chat_channel this user was from diff --git a/src/DMAPI/tgs/v4/api.dm b/src/DMAPI/tgs/v4/api.dm index a6700ab4e5..e62de1c25e 100644 --- a/src/DMAPI/tgs/v4/api.dm +++ b/src/DMAPI/tgs/v4/api.dm @@ -211,7 +211,7 @@ channel.friendly_name = channel_json["friendly_name"] channel.connection_name = channel_json["connection_name"] channel.is_admin_channel = channel_json["is_admin_channel"] - channel.is_admin_channel = channel_json["is_private_channel"] || FALSE + channel.is_private_channel = channel_json["is_private_channel"] || FALSE return channel #undef TGS4_TOPIC_COMMAND diff --git a/src/Tgstation.Server.Api/Models/ChatChannel.cs b/src/Tgstation.Server.Api/Models/ChatChannel.cs index 942154e506..d5131f4ff1 100644 --- a/src/Tgstation.Server.Api/Models/ChatChannel.cs +++ b/src/Tgstation.Server.Api/Models/ChatChannel.cs @@ -13,7 +13,7 @@ /// /// The Discord channel ID /// - public long? DiscordChannelId { get; set; } + public ulong? DiscordChannelId { get; set; } /// /// If the is an admin channel diff --git a/src/Tgstation.Server.Api/Models/CompileJob.cs b/src/Tgstation.Server.Api/Models/CompileJob.cs index 0d381b559b..44a3b7baad 100644 --- a/src/Tgstation.Server.Api/Models/CompileJob.cs +++ b/src/Tgstation.Server.Api/Models/CompileJob.cs @@ -14,5 +14,10 @@ namespace Tgstation.Server.Api.Models /// Git revision the compiler ran on. Not modifiable /// public RevisionInformation RevisionInformation { get; set; } + + /// + /// The the was made with + /// + public Version ByondVersion { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs b/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs index 6255f1591c..7f717c121c 100644 --- a/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs +++ b/src/Tgstation.Server.Api/Models/Internal/CompileJob.cs @@ -37,10 +37,5 @@ namespace Tgstation.Server.Api.Models.Internal /// Exit code of DM. If /// public int? ExitCode { get; set; } - - /// - /// The the was made with - /// - public Version ByondVersion { get; set; } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Channel.cs b/src/Tgstation.Server.Host/Components/Chat/Channel.cs index c2eab84dd8..07111826d8 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Channel.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Channel.cs @@ -1,15 +1,29 @@ -namespace Tgstation.Server.Host.Components.Chat +using Newtonsoft.Json; +using System; +using System.Globalization; + +namespace Tgstation.Server.Host.Components.Chat { /// /// Represents a channel /// public sealed class Channel { + /// + /// Backing field for . Represented as a to avoid BYOND percision loss + /// + public string Id { get; set; } + /// /// The channel Id. /// /// remaps this to an internal id using - public long Id { get; set; } + [JsonIgnore] + public ulong RealId + { + get => UInt64.Parse(Id, CultureInfo.InvariantCulture); + set => Id = value.ToString(CultureInfo.InvariantCulture); + } /// /// The user friendly name of the diff --git a/src/Tgstation.Server.Host/Components/Chat/ChannelMapping.cs b/src/Tgstation.Server.Host/Components/Chat/ChannelMapping.cs index 243bf71206..5185f033cc 100644 --- a/src/Tgstation.Server.Host/Components/Chat/ChannelMapping.cs +++ b/src/Tgstation.Server.Host/Components/Chat/ChannelMapping.cs @@ -1,11 +1,28 @@ namespace Tgstation.Server.Host.Components.Chat { + /// + /// Represents a mapping of a + /// sealed class ChannelMapping { + /// + /// The Id of the + /// public long ProviderId { get; set; } - public long ProviderChannelId { get; set; } + + /// + /// The original + /// + public ulong ProviderChannelId { get; set; } + + /// + /// If is a watchdog channel + /// public bool IsWatchdogChannel { get; set; } + /// + /// The with the mapped Id + /// public Channel Channel { get; set; } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Chat.cs b/src/Tgstation.Server.Host/Components/Chat/Chat.cs index 07a29865c9..7febaa1f3b 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Chat.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Chat.cs @@ -1,4 +1,6 @@ -using System; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -13,6 +15,8 @@ namespace Tgstation.Server.Host.Components.Chat /// sealed class Chat : IChat { + const string CommonMention = "!tgs"; + /// /// The for the /// @@ -24,9 +28,14 @@ namespace Tgstation.Server.Host.Components.Chat readonly IIOManager ioManager; /// - /// s that never change + /// The for the /// - readonly IReadOnlyList builtinCommands; + readonly ILogger logger; + + /// + /// Unchanging s in the mapped by + /// + readonly Dictionary builtinCommands; /// /// Map of s in use, keyed by @@ -34,9 +43,9 @@ namespace Tgstation.Server.Host.Components.Chat readonly Dictionary providers; /// - /// Map of s to s + /// Map of s to s /// - readonly Dictionary mappedChannels; + readonly Dictionary mappedChannels; /// /// The active s for the @@ -44,41 +53,57 @@ namespace Tgstation.Server.Host.Components.Chat readonly List trackingContexts; /// - /// The for the + /// The for + /// + readonly CancellationTokenSource handlerCts; + + /// + /// The for the /// ICustomCommandHandler customCommandHandler; /// - /// Used for remapping s + /// The that monitors incoming chat messages /// - long channelIdCounter; + Task chatHandler; + + /// + /// Used for remapping s + /// + ulong channelIdCounter; /// /// If has been called /// - bool started; + bool started; /// /// Construct a /// /// The value of /// The value of + /// The value of /// The used to populate - public Chat(IProviderFactory providerFactory, IIOManager ioManager, ICommandFactory commandFactory) + public Chat(IProviderFactory providerFactory, IIOManager ioManager, ILogger logger, ICommandFactory commandFactory) { this.providerFactory = providerFactory ?? throw new ArgumentNullException(nameof(providerFactory)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); - builtinCommands = commandFactory?.GenerateCommands() ?? throw new ArgumentNullException(nameof(commandFactory)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + builtinCommands = new Dictionary(); + foreach (var I in commandFactory?.GenerateCommands() ?? throw new ArgumentNullException(nameof(commandFactory))) + builtinCommands.Add(I.Name, I); providers = new Dictionary(); - mappedChannels = new Dictionary(); + mappedChannels = new Dictionary(); trackingContexts = new List(); + handlerCts = new CancellationTokenSource(); channelIdCounter = 1; } /// public void Dispose() { + handlerCts.Dispose(); foreach (var I in providers) I.Value.Dispose(); } @@ -112,6 +137,108 @@ namespace Tgstation.Server.Host.Components.Chat return provider; } + /// + /// Processes a + /// + /// The who recevied + /// The to process + /// The for the operation + /// A representing the running operation + async Task ProcessMessage(IProvider provider, Message message, CancellationToken cancellationToken) + { + logger.LogTrace("Chat message: {0}. User (Note unconverted provider Id): {1}", message.Content, JsonConvert.SerializeObject(message.User)); + + var splits = new List(message.Content.Split(' ')); + var address = splits[0]; + if (address.Length > 1 && (address[address.Length - 1] == ':' || address[address.Length - 1] == ',')) + address = address.Substring(0, address.Length - 1); + + address = address.ToUpperInvariant(); + + if (address != CommonMention.ToUpperInvariant() && address != provider.BotMention.ToUpperInvariant()) + //no mention + return; + + if (splits.Count == 1) + { + //just a mention + await SendMessage("Hi!", new List { message.User.Channel.RealId }, cancellationToken).ConfigureAwait(false); + return; + } + + splits.RemoveAt(0); + + var command = splits[0].ToUpperInvariant(); + splits.RemoveAt(0); + var arguments = String.Join(" ", splits); + + try + { + if (!builtinCommands.TryGetValue(command, out ICommand commandHandler)) + { + var tasks = trackingContexts.Select(x => x.GetCustomCommands(cancellationToken)); + await Task.WhenAll(tasks).ConfigureAwait(false); + commandHandler = tasks.SelectMany(x => x.Result).Where(x => x.Name.ToUpperInvariant() == command).FirstOrDefault(); + } + + if (command == default) + { + await SendMessage("Invalid command! Type '?' or 'help' for available commands.", new List { message.User.Channel.RealId }, cancellationToken).ConfigureAwait(false); + return; + } + + var result = await commandHandler.Invoke(arguments, message.User, cancellationToken).ConfigureAwait(false); + if(result != null) + await SendMessage(result, new List { message.User.Channel.RealId }, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + //error bc custom commands should reply about why it failed + logger.LogError("Error processing chat command: {0}", e); + await SendMessage("Internal error processing command!", new List { message.User.Channel.RealId }, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Monitors active providers for new s + /// + /// The for the operation + /// A representing the running operation + async Task MonitorMessages(CancellationToken cancellationToken) + { + var messageTasks = new Dictionary>(); + try + { + while (!cancellationToken.IsCancellationRequested) + { + //prune disconnected providers + foreach (var I in messageTasks) + if (!I.Key.Connected) + messageTasks.Remove(I.Key); + + //add new ones + foreach (var I in providers) + if (I.Value.Connected && !messageTasks.ContainsKey(I.Value)) + messageTasks.Add(I.Value, I.Value.NextMessage(cancellationToken)); + + //wait for a message + var tasks = messageTasks.Select(x => x.Value); + await Task.WhenAny().ConfigureAwait(false); + + //process completed ones + foreach (var I in messageTasks.Where(x => x.Value.IsCompleted)) + { + messageTasks.Remove(I.Key); + + var message = await I.Value.ConfigureAwait(false); + + await ProcessMessage(I.Key, message, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) { } + } + /// public async Task ChangeChannels(long connectionId, IEnumerable newChannels, CancellationToken cancellationToken) { @@ -126,16 +253,16 @@ namespace Tgstation.Server.Host.Components.Chat var mappings = Enumerable.Zip(newChannels, results, (x, y) => new ChannelMapping { IsWatchdogChannel = x.IsWatchdogChannel, - ProviderChannelId = y.Id, + ProviderChannelId = y.RealId, ProviderId = connectionId, Channel = y }); - long baseId; + ulong baseId; lock (this) { baseId = channelIdCounter; - channelIdCounter += results.Count; + channelIdCounter += (ulong)results.Count; } Task task; @@ -148,7 +275,7 @@ namespace Tgstation.Server.Host.Components.Chat { var newId = baseId++; mappedChannels.Add(newId, I); - I.Channel.Id = newId; + I.Channel.RealId = newId; } lock (trackingContexts) @@ -185,7 +312,7 @@ namespace Tgstation.Server.Host.Components.Chat } /// - public Task SendMessage(string message, IEnumerable channelIds, CancellationToken cancellationToken) + public Task SendMessage(string message, IEnumerable channelIds, CancellationToken cancellationToken) { if (message == null) throw new ArgumentNullException(nameof(message)); @@ -209,7 +336,7 @@ namespace Tgstation.Server.Host.Components.Chat /// public Task SendWatchdogMessage(string message, CancellationToken cancellationToken) { - List wdChannels; + List wdChannels; lock (mappedChannels) //so it doesn't change while we're using it wdChannels = mappedChannels.Where(x => x.Value.IsWatchdogChannel).Select(x => x.Key).ToList(); return SendMessage(message, wdChannels, cancellationToken); @@ -219,11 +346,17 @@ namespace Tgstation.Server.Host.Components.Chat public async Task StartAsync(CancellationToken cancellationToken) { await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Connect(cancellationToken))).ConfigureAwait(false); + chatHandler = MonitorMessages(handlerCts.Token); started = true; } /// - public Task StopAsync(CancellationToken cancellationToken) => Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Disconnect(cancellationToken))); + public async Task StopAsync(CancellationToken cancellationToken) + { + handlerCts.Cancel(); + await chatHandler.ConfigureAwait(false); + await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Disconnect(cancellationToken))).ConfigureAwait(false); + } /// public async Task TrackJsons(string basePath, string channelsJsonName, string commandsJsonName, CancellationToken cancellationToken) diff --git a/src/Tgstation.Server.Host/Components/Chat/ChatFactory.cs b/src/Tgstation.Server.Host/Components/Chat/ChatFactory.cs new file mode 100644 index 0000000000..82f5cfc72c --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/ChatFactory.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Logging; +using System; +using Tgstation.Server.Host.Components.Chat.Commands; +using Tgstation.Server.Host.Core; + +namespace Tgstation.Server.Host.Components.Chat +{ + /// + sealed class ChatFactory : IChatFactory + { + /// + /// The for the + /// + readonly IIOManager ioManager; + + /// + /// The for the + /// + readonly ILoggerFactory loggerFactory; + + /// + /// The for the + /// + readonly ICommandFactory commandFactory; + + /// + /// Construct a + /// + /// The value of + /// The value of + /// The value of + public ChatFactory(IIOManager ioManager, ILoggerFactory loggerFactory, ICommandFactory commandFactory) + { + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory)); + } + + /// + public IChat CreateChat() => new Chat(new ProviderFactory(), ioManager, loggerFactory.CreateLogger(), commandFactory); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/BoolConverter.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/BoolConverter.cs new file mode 100644 index 0000000000..e3103e4298 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/BoolConverter.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; +using System; + +namespace Tgstation.Server.Host.Components.Chat.Commands +{ + /// + /// for decoding bools returned by BYOND + /// + sealed class BoolConverter : JsonConverter + { + /// + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) => writer.WriteValue(((bool)value) ? 1 : 0); + + /// + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) => reader.Value.ToString() == "1"; + + /// + public override bool CanConvert(Type objectType) => objectType == typeof(bool); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/Command.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/Command.cs deleted file mode 100644 index d5f192ec59..0000000000 --- a/src/Tgstation.Server.Host/Components/Chat/Commands/Command.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace Tgstation.Server.Host.Components.Chat.Commands -{ - /// - /// Represents a command that can be invoked by talking to chat bots - /// - public abstract class Command - { - /// - /// The text to invoke the command. May not be "?" or "help" (case-insensitive) - /// - public string Name { get; set; } - - /// - /// The help text to display when queires are made about the command - /// - public string HelpText { get; set; } - - /// - /// If the command should only be available to s who's has set - /// - public bool AdminOnly { get; set; } - - /// - /// Invoke the - /// - /// The text after with leading whitespace trimmed - public abstract void Invoke(string arguments); - } -} diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/CommandFactory.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/CommandFactory.cs new file mode 100644 index 0000000000..a9bd873534 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/CommandFactory.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using Tgstation.Server.Host.Core; + +namespace Tgstation.Server.Host.Components.Chat.Commands +{ + /// + sealed class CommandFactory : ICommandFactory + { + /// + /// The for the + /// + readonly IApplication application; + + /// + /// Construct a + /// + /// The value of + public CommandFactory(IApplication application) + { + this.application = application ?? throw new ArgumentNullException(nameof(application)); + } + + /// + public IReadOnlyList GenerateCommands() => new List + { + new KekCommand(), + new VersionCommand(application) + }; + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/CustomCommand.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/CustomCommand.cs index 6221f531c1..da23754a2d 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Commands/CustomCommand.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/CustomCommand.cs @@ -1,12 +1,25 @@ -using System; +using Newtonsoft.Json; +using System; +using System.Threading; +using System.Threading.Tasks; namespace Tgstation.Server.Host.Components.Chat.Commands { /// /// Represents a command made from DM code /// - public sealed class CustomCommand : Command + public sealed class CustomCommand : ICommand { + /// + public string Name { get; set; } + + /// + public string HelpText { get; set; } + + /// + [JsonConverter(typeof(BoolConverter))] + public bool AdminOnly { get; set; } + /// /// The for the /// @@ -24,10 +37,11 @@ namespace Tgstation.Server.Host.Components.Chat.Commands } /// - public override void Invoke(string arguments) + public Task Invoke(string arguments, User user, CancellationToken cancellationToken) { if (handler == null) throw new InvalidOperationException("SetHandler() has not been called!"); + return handler.HandleChatCommand(Name, arguments, user, cancellationToken); } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/ICommand.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/ICommand.cs new file mode 100644 index 0000000000..8694606731 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/ICommand.cs @@ -0,0 +1,35 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Components.Chat.Commands +{ + /// + /// Represents a command that can be invoked by talking to chat bots + /// + public interface ICommand + { + /// + /// The text to invoke the command. May not be "?" or "help" (case-insensitive) + /// + string Name { get; } + + /// + /// The help text to display when queires are made about the command + /// + string HelpText { get; } + + /// + /// If the command should only be available to s who's has set + /// + bool AdminOnly { get; } + + /// + /// Invoke the + /// + /// The text after with leading whitespace trimmed + /// The who invoked the command + /// The for the operation + /// A resulting in a to send to the invoker + Task Invoke(string arguments, User user, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/ICommandFactory.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/ICommandFactory.cs new file mode 100644 index 0000000000..5520126604 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/ICommandFactory.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Host.Components.Chat.Commands +{ + /// + /// Factory for built in s + /// + interface ICommandFactory + { + /// + /// Generate builtin s + /// + /// A of s + IReadOnlyList GenerateCommands(); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/KekCommand.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/KekCommand.cs new file mode 100644 index 0000000000..7fa541ef37 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/KekCommand.cs @@ -0,0 +1,28 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Components.Chat.Commands +{ + /// + /// kek + /// + sealed class KekCommand : ICommand + { + /// + /// kek + /// + const string Kek = "kek"; + + /// + public string Name => Kek; + + /// + public string HelpText => Kek; + + /// + public bool AdminOnly => false; + + /// + public Task Invoke(string arguments, User user, CancellationToken cancellationToken) => Task.FromResult(Kek); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/VersionCommand.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/VersionCommand.cs new file mode 100644 index 0000000000..13b5a68f74 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/VersionCommand.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Core; + +namespace Tgstation.Server.Host.Components.Chat.Commands +{ + /// + /// to return the + /// + sealed class VersionCommand : ICommand + { + /// + public string Name => "version"; + + /// + public string HelpText => "Displays the tgstation server version"; + + /// + public bool AdminOnly => false; + + /// + /// The for the + /// + readonly IApplication application; + + /// + /// Construct a + /// + /// + public VersionCommand(IApplication application) + { + this.application = application ?? throw new ArgumentNullException(nameof(application)); + } + + /// + public Task Invoke(string arguments, User user, CancellationToken cancellationToken) => Task.FromResult(application.VersionString); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/IChat.cs b/src/Tgstation.Server.Host/Components/Chat/IChat.cs index 68c12aa1ed..15e6e939bc 100644 --- a/src/Tgstation.Server.Host/Components/Chat/IChat.cs +++ b/src/Tgstation.Server.Host/Components/Chat/IChat.cs @@ -57,7 +57,7 @@ namespace Tgstation.Server.Host.Components.Chat /// The s of the s to send to /// The for the operation /// A representing the running operation - Task SendMessage(string message, IEnumerable channelIds, CancellationToken cancellationToken); + Task SendMessage(string message, IEnumerable channelIds, CancellationToken cancellationToken); /// /// Send a chat to configured watchdog channels diff --git a/src/Tgstation.Server.Host/Components/Chat/IChatFactory.cs b/src/Tgstation.Server.Host/Components/Chat/IChatFactory.cs new file mode 100644 index 0000000000..734b6243f3 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/IChatFactory.cs @@ -0,0 +1,14 @@ +namespace Tgstation.Server.Host.Components.Chat +{ + /// + /// For creating s + /// + interface IChatFactory + { + /// + /// Create a + /// + /// A new + IChat CreateChat(); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/ICommandFactory.cs b/src/Tgstation.Server.Host/Components/Chat/ICommandFactory.cs deleted file mode 100644 index 4020ab0613..0000000000 --- a/src/Tgstation.Server.Host/Components/Chat/ICommandFactory.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; -using Tgstation.Server.Host.Components.Chat.Commands; - -namespace Tgstation.Server.Host.Components.Chat -{ - interface ICommandFactory - { - IReadOnlyList GenerateCommands(); - } -} diff --git a/src/Tgstation.Server.Host/Components/Chat/ICustomCommandHandler.cs b/src/Tgstation.Server.Host/Components/Chat/ICustomCommandHandler.cs index 5106ba0efd..9f640cb633 100644 --- a/src/Tgstation.Server.Host/Components/Chat/ICustomCommandHandler.cs +++ b/src/Tgstation.Server.Host/Components/Chat/ICustomCommandHandler.cs @@ -1,11 +1,10 @@ -using System.Collections.Generic; -using System.Threading; +using System.Threading; using System.Threading.Tasks; namespace Tgstation.Server.Host.Components.Chat { /// - /// Handles that map to those defined in a + /// Handles s that map to those defined in a /// public interface ICustomCommandHandler { diff --git a/src/Tgstation.Server.Host/Components/Chat/Message.cs b/src/Tgstation.Server.Host/Components/Chat/Message.cs index e9b90e6560..3989369ce8 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Message.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Message.cs @@ -1,8 +1,18 @@ namespace Tgstation.Server.Host.Components.Chat.Providers { + /// + /// Represents a message recieved by a + /// sealed class Message { - string Content { get; set; } - User User { get; set; } + /// + /// The text of the message + /// + public string Content { get; set; } + + /// + /// The who sent the + /// + public User User { get; set; } } } \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs new file mode 100644 index 0000000000..4412cf16b5 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/DiscordProvider.cs @@ -0,0 +1,207 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Components.Chat.Providers +{ + /// + /// for the Discord app + /// + sealed class DiscordProvider : Provider + { + /// + public override bool Connected => client.ConnectionState == ConnectionState.Connected; + + /// + public override string BotMention + { + get + { + if (!Connected) + throw new InvalidOperationException("Provider not connected"); + return client.CurrentUser.Mention; + } + } + + /// + /// The for the + /// + readonly ILogger logger; + + /// + /// The for the + /// + readonly DiscordSocketClient client; + + /// + /// The token used for connecting to discord + /// + readonly string botToken; + + /// + /// of mapped s + /// + readonly List mappedChannels; + + /// + /// Construct a + /// + /// The value of + /// The value of + public DiscordProvider(ILogger logger, string botToken) + { + 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(); + } + + /// + public override void Dispose() => client.Dispose(); + + /// + /// Handle a message recieved from Discord + /// + /// The + /// A representing the running operation + Task Client_MessageReceived(SocketMessage e) + { + if (e.Author.Id != client.CurrentUser.Id) + return Task.CompletedTask; + + var pm = e.Channel is IPrivateChannel; + + if (!pm && !mappedChannels.Contains(e.Channel.Id)) + return Task.CompletedTask; + + var result = new Message { + Content = e.Content, + User = new User + { + RealId = e.Author.Id, + Channel = new Channel + { + RealId = e.Channel.Id, + IsAdmin = false, + IsPrivate = true, + ConnectionName = pm ? e.Author.Username : (e.Channel as ITextChannel)?.Guild.Name ?? "UNKNOWN", + FriendlyName = e.Channel.Name + }, + FriendlyName = e.Author.Username, + Mention = e.Author.Mention + } + }; + EnqueueMessage(result); + return Task.CompletedTask; + } + + /// + public override async Task Connect(CancellationToken cancellationToken) + { + if (Connected) + return true; + + try + { + await client.LoginAsync(TokenType.Bot, botToken, true).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + + await client.StartAsync().ConfigureAwait(false); + + var channelsAvailable = new TaskCompletionSource(); + client.Ready += () => + { + channelsAvailable.SetResult(null); + return Task.CompletedTask; + }; + using (cancellationToken.Register(() => channelsAvailable.SetCanceled())) + await channelsAvailable.Task.ConfigureAwait(false); + } + catch (Exception e) + { + logger.LogWarning("Error connecting to Discord: {0}", e); + return false; + } + + return true; + } + + public override async Task Disconnect(CancellationToken cancellationToken) + { + if (!Connected) + return; + + try + { + await client.StopAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + await client.LogoutAsync().ConfigureAwait(false); + } + catch (Exception e) + { + logger.LogWarning("Error disconnecting from discord: {0}", e); + } + } + + /// + public override Task> MapChannels(IEnumerable channels, CancellationToken cancellationToken) + { + if (channels == null) + throw new ArgumentNullException(nameof(channels)); + + if (!Connected) + throw new InvalidOperationException("Provider not connected!"); + + Channel GetChannelForChatChannel(ChatChannel channel) + { + if (!channel.DiscordChannelId.HasValue) + throw new InvalidOperationException("ChatChannel missing DiscordChannelId!"); + + if (!(client.GetChannel(channel.DiscordChannelId.Value) is ITextChannel discordChannel)) + return null; + + return new Channel + { + RealId = discordChannel.Id, + IsAdmin = channel.IsAdminChannel, + ConnectionName = discordChannel.Guild.Name, + FriendlyName = discordChannel.Name, + IsPrivate = false + }; + }; + + var enumerator = channels.Select(x => GetChannelForChatChannel(x)).Where(x => x != null); + + lock (this) + { + mappedChannels.Clear(); + mappedChannels.AddRange(enumerator.Select(x => x.RealId)); + } + + return Task.FromResult>(enumerator.ToList()); + } + + /// + public override async Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken) { + try + { + await ((client.GetChannel(channelId) as ITextChannel)?.SendMessageAsync(message, false, null, new RequestOptions + { + CancelToken = cancellationToken + }) ?? Task.CompletedTask).ConfigureAwait(false); + } + catch (Exception e) + { + logger.LogWarning("Error sending discord message: {0}", e); + } + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs index 7abe357269..99d13e1768 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs @@ -23,7 +23,10 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// Get a resulting in the next the recieves or on a disconnect /// - Task NextMessage { get; } + /// The for the operation + /// A resulting in the next available + /// Note that private messages will come in the form of s not returned in + Task NextMessage(CancellationToken cancellationToken); /// /// Attempt to connect the @@ -50,10 +53,10 @@ namespace Tgstation.Server.Host.Components.Chat.Providers /// /// Send a message to the /// - /// The to send to + /// The to send to /// The message contents /// The for the operation /// A representing the running operation - Task SendMessage(long channelId, string message, CancellationToken cancellationToken); + Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken); } } diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcPasswordType.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcPasswordType.cs new file mode 100644 index 0000000000..d31a3aba73 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcPasswordType.cs @@ -0,0 +1,21 @@ +namespace Tgstation.Server.Host.Components.Chat.Providers +{ + /// + /// Represents the type of a password passed to the constructor of + /// + enum IrcPasswordType + { + /// + /// Use server authentication + /// + Server, + /// + /// Use PLAIN sasl authentication + /// + Sasl, + /// + /// Use NickServ authentication + /// + NickServ + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs new file mode 100644 index 0000000000..7457d6f8d2 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IrcProvider.cs @@ -0,0 +1,344 @@ +using Meebey.SmartIrc4net; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Core; + +namespace Tgstation.Server.Host.Components.Chat.Providers +{ + /// + /// for internet relay chat + /// + sealed class IrcProvider : Provider + { + const int TimeoutSeconds = 5; + + /// + public override bool Connected => client.IsConnected; + + /// + public override string BotMention => client.Nickname; + + /// + /// The for the + /// + readonly ILogger logger; + + /// + /// The client + /// + readonly IrcFeatures client; + + /// + /// Address of the server to connect to + /// + readonly string address; + /// + /// Port of the server to connect to + /// + readonly ushort port; + /// + /// IRC nickname + /// + readonly string nickname; + /// + /// Password which will used for authentication + /// + readonly string password; + /// + /// The of + /// + readonly IrcPasswordType? passwordType; + + /// + /// Map of s to channel names + /// + readonly Dictionary channelIdMap; + + /// + /// Map of s to query users + /// + readonly Dictionary queryChannelIdMap; + + /// + /// Id counter for + /// + ulong channelIdCounter; + + /// + /// Construct an + /// + /// The value of logger + /// The to get the from + /// The value of + /// The value of + /// The value of + /// The value of + /// The value of + /// If should be used + public IrcProvider(ILogger logger, IApplication application, string address, ushort port, string nickname, string password, IrcPasswordType? passwordType, bool useSsl) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + if (application == null) + throw new ArgumentNullException(nameof(application)); + + this.address = address ?? throw new ArgumentNullException(nameof(address)); + this.port = port; + this.nickname = nickname ?? throw new ArgumentNullException(nameof(nickname)); + + if (passwordType.HasValue && password == null) + throw new ArgumentNullException(nameof(password)); + + if(password != null && !passwordType.HasValue) + throw new ArgumentNullException(nameof(passwordType)); + + this.password = password; + this.passwordType = 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 = true, + AutoRetryLimit = TimeoutSeconds, + AutoRetryDelay = TimeoutSeconds, + ActiveChannelSyncing = true, + AutoNickHandling = true, + CtcpVersion = application.VersionString, + UseSsl = useSsl + }; + if (useSsl) + client.ValidateServerCertificate = true; //dunno if it defaults to that or what + + client.OnChannelMessage += Client_OnChannelMessage; + client.OnQueryMessage += Client_OnQueryMessage; + + channelIdMap = new Dictionary(); + queryChannelIdMap = new Dictionary(); + channelIdCounter = 1; + } + + /// + public override void Dispose() => client.Disconnect(); //just closes the socket + + /// + /// Handle an IRC message + /// + /// The + /// If this is a query message + void HandleMessage(IrcEventArgs e, bool isPrivate) + { + if (e.Data.From.ToUpperInvariant() == client.Nickname.ToUpperInvariant()) + return; + + var username = e.Data.From; + var channelName = isPrivate ? username : e.Data.Channel; + ulong channelId = 0; + lock (this) + { + var dicToCheck = isPrivate ? queryChannelIdMap : channelIdMap; + if (!dicToCheck.Any(x => + { + if (x.Value != channelName) + return false; + channelId = x.Key; + return true; + })) + { + channelId = ++channelIdCounter; + dicToCheck.Add(channelId, channelName); + if (isPrivate) + channelIdMap.Add(channelId, null); + } + } + + var message = new Message + { + Content = e.Data.Message, + User = new User + { + Channel = new Channel + { + IsAdmin = false, + ConnectionName = address, + FriendlyName = isPrivate ? String.Format(CultureInfo.InvariantCulture, "PM: {0}", channelName) : channelName, + RealId = channelId, + IsPrivate = isPrivate + }, + FriendlyName = username, + RealId = channelId, + Mention = username + } + }; + + EnqueueMessage(message); + } + + /// + /// When a query message is received in IRC + /// + /// The sender of the event + /// The + void Client_OnQueryMessage(object sender, IrcEventArgs e) => HandleMessage(e, true); + + /// + /// When a channel message is received in IRC + /// + /// The sender of the event + /// The + void Client_OnChannelMessage(object sender, IrcEventArgs e) => HandleMessage(e, false); + + /// + public override Task Connect(CancellationToken cancellationToken) => Task.Factory.StartNew(() => + { + try + { + client.Connect(address, port); + + cancellationToken.ThrowIfCancellationRequested(); + + if (passwordType == IrcPasswordType.Server) + client.Login(nickname, nickname, 0, nickname, password); + else + { + if (passwordType == IrcPasswordType.Sasl) + { + client.WriteLine("CAP REQ :sasl", Priority.Critical); //needs to be put in the buffer before anything else + cancellationToken.ThrowIfCancellationRequested(); + } + client.Login(nickname, nickname, 0, nickname); + } + + if (passwordType == IrcPasswordType.NickServ) + { + cancellationToken.ThrowIfCancellationRequested(); + client.SendMessage(SendType.Message, "NickServ", String.Format(CultureInfo.InvariantCulture, "IDENTIFY {0}", password)); + } + else if (passwordType == IrcPasswordType.Sasl) + { + //wait for the sasl ack or timeout + var recievedAck = false; + var recievedPlus = false; + client.OnReadLine += (sender, e) => + { + if (e.Line.Contains("ACK :sasl")) + recievedAck = true; + else if (e.Line.Contains("AUTHENTICATE +")) + recievedPlus = true; + }; + + var startTime = DateTimeOffset.Now; + var endTime = DateTimeOffset.Now.AddSeconds(TimeoutSeconds); + cancellationToken.ThrowIfCancellationRequested(); + for(; !recievedAck && DateTimeOffset.Now <= endTime; Task.Delay(10, cancellationToken).GetAwaiter().GetResult()) + client.Listen(false); + + client.WriteLine("AUTHENTICATE PLAIN", Priority.Critical); + cancellationToken.ThrowIfCancellationRequested(); + + for (; !recievedPlus && DateTimeOffset.Now <= endTime; Task.Delay(10, cancellationToken).GetAwaiter().GetResult()) + client.Listen(false); + + //Stolen! https://github.com/znc/znc/blob/1e697580155d5a38f8b5a377f3b1d94aaa979539/modules/sasl.cpp#L196 + var authString = String.Format(CultureInfo.InvariantCulture, "{0}{1}{0}{1}{2}", nickname, '\0', password); + var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(authString)); + var authLine = String.Format(CultureInfo.InvariantCulture, "AUTHENTICATE {0}", b64); + var chars = authLine.ToCharArray(); + client.WriteLine(authLine, Priority.Critical); + + cancellationToken.ThrowIfCancellationRequested(); + client.WriteLine("CAP END", Priority.Critical); + } + } + catch (Exception e) + { + logger.LogWarning("Unable to connect to IRC: {0}", e); + } + return true; + }, cancellationToken,TaskCreationOptions.LongRunning, TaskScheduler.Current); + + /// + public override Task Disconnect(CancellationToken cancellationToken) => Task.Factory.StartNew(() => + { + try + { + client.RfcQuit(); + } + catch (Exception e) + { + logger.LogWarning("Error quitting IRC: {0}", e); + } + Dispose(); + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + + /// + public override Task> MapChannels(IEnumerable channels, CancellationToken cancellationToken) => Task.Factory.StartNew(() => + { + if (channels.Any(x => x.IrcChannel == null)) + throw new InvalidOperationException("ChatChannel missing IrcChannel!"); + lock (this) + { + var hs = new HashSet(); //for unique inserts + foreach (var I in channels) + hs.Add(I.IrcChannel); + var toPart = new List(); + foreach (var I in client.JoinedChannels) + if (!hs.Remove(I)) + toPart.Add(I); + + foreach (var I in toPart) + client.RfcPart(I); + foreach (var I in hs) + client.RfcJoin(I); + + + + return (IReadOnlyList)channels.Select(x => { + ulong id = channelIdCounter; + if (!channelIdMap.Any(y => + { + if (y.Value != x.IrcChannel) + return false; + id = y.Key; + return true; + })) + channelIdMap.Add(id, x.IrcChannel); + else + ++channelIdCounter; + return new Channel + { + RealId = id, + IsAdmin = x.IsAdminChannel, + ConnectionName = address, + FriendlyName = channelIdMap[id], + IsPrivate = false + }; + }).ToList(); + } + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + + /// + public override Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken) => Task.Factory.StartNew(() => + { + var channelName = channelIdMap[channelId] ?? queryChannelIdMap[channelId]; + try + { + if (client.JoinedChannels.Contains(channelName)) + client.SendMessage(SendType.Message, channelName, message); + } + catch(Exception e) + { + logger.LogWarning("Unable to send to channel: {0}", e); + } + }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Current); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs new file mode 100644 index 0000000000..01484c229d --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/Provider.cs @@ -0,0 +1,76 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models; + +namespace Tgstation.Server.Host.Components.Chat.Providers +{ + /// + abstract class Provider : IProvider + { + /// + /// of received s + /// + readonly Queue messageQueue; + + /// + /// that completes while isn't empty + /// + TaskCompletionSource nextMessage; + + protected Provider() + { + messageQueue = new Queue(); + nextMessage = new TaskCompletionSource(); + } + + /// + public abstract bool Connected { get; } + + /// + public abstract string BotMention { get; } + + /// + /// Queues a for + /// + /// The to queue + protected void EnqueueMessage(Message message) + { + lock (messageQueue) + { + messageQueue.Enqueue(message); + nextMessage.TrySetResult(null); + } + } + + /// + public abstract void Dispose(); + + /// + public abstract Task Connect(CancellationToken cancellationToken); + + /// + public abstract Task Disconnect(CancellationToken cancellationToken); + + /// + public abstract Task> MapChannels(IEnumerable channels, CancellationToken cancellationToken); + + /// + public async Task NextMessage(CancellationToken cancellationToken) + { + var cancelTcs = new TaskCompletionSource(); + using (cancellationToken.Register(() => cancelTcs.SetCanceled())) + await Task.WhenAny(nextMessage.Task, cancelTcs.Task).ConfigureAwait(false); + lock (messageQueue) + { + var result = messageQueue.Dequeue(); + if (messageQueue.Count == 0) + nextMessage = new TaskCompletionSource(); + return result; + } + } + + /// + public abstract Task SendMessage(ulong channelId, string message, CancellationToken cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Response.cs b/src/Tgstation.Server.Host/Components/Chat/Response.cs index 855752e790..59be02b4ba 100644 --- a/src/Tgstation.Server.Host/Components/Chat/Response.cs +++ b/src/Tgstation.Server.Host/Components/Chat/Response.cs @@ -15,6 +15,6 @@ namespace Tgstation.Server.Host.Components.Chat /// /// The list of internal channel ids to send to /// - public List ChannelIds { get; set; } + public List ChannelIds { get; set; } } } diff --git a/src/Tgstation.Server.Host/Components/Chat/User.cs b/src/Tgstation.Server.Host/Components/Chat/User.cs index 12eff58285..c3a5e2b6dc 100644 --- a/src/Tgstation.Server.Host/Components/Chat/User.cs +++ b/src/Tgstation.Server.Host/Components/Chat/User.cs @@ -1,13 +1,42 @@ -namespace Tgstation.Server.Host.Components.Chat +using Newtonsoft.Json; +using System; +using System.Globalization; + +namespace Tgstation.Server.Host.Components.Chat { /// - /// + /// Represents a tgs_chat_user datum /// public sealed class User { - long Id { get; set; } - string FriendlyName { get; set; } - string Mention { get; set; } - Channel Channel { get; set; } + /// + /// Backing field for . Represented as a to avoid BYOND percision loss + /// + public string Id { get; set; } + + /// + /// The internal user id + /// + [JsonIgnore] + public ulong RealId + { + get => UInt64.Parse(Id, CultureInfo.InvariantCulture); + set => Id = value.ToString(CultureInfo.InvariantCulture); + } + + /// + /// The friendly name of the user + /// + public string FriendlyName { get; set; } + + /// + /// The text to mention the user + /// + public string Mention { get; set; } + + /// + /// The the user spoke from + /// + public Channel Channel { get; set; } } } diff --git a/src/Tgstation.Server.Host/Components/DreamMaker.cs b/src/Tgstation.Server.Host/Components/DreamMaker.cs index aaeb1204e3..5d3caa81d1 100644 --- a/src/Tgstation.Server.Host/Components/DreamMaker.cs +++ b/src/Tgstation.Server.Host/Components/DreamMaker.cs @@ -275,7 +275,7 @@ namespace Tgstation.Server.Host.Components bool ddVerified; using (var byondLock = byond.UseExecutables(null)) { - job.ByondVersion = byondLock.Version; + job.ByondVersion = byondLock.Version.ToString(); await RunDreamMaker(byondLock.DreamMakerPath, job, cancellationToken).ConfigureAwait(false); diff --git a/src/Tgstation.Server.Host/Components/EventType.cs b/src/Tgstation.Server.Host/Components/EventType.cs index 53e6c9c9bd..615c7ed3bb 100644 --- a/src/Tgstation.Server.Host/Components/EventType.cs +++ b/src/Tgstation.Server.Host/Components/EventType.cs @@ -3,7 +3,7 @@ /// /// Types of events /// - enum EventType + public enum EventType { /// /// Parameters: Reference name, commit sha diff --git a/src/Tgstation.Server.Host/Components/IEventConsumer.cs b/src/Tgstation.Server.Host/Components/IEventConsumer.cs index 322860c7b3..986f9f2213 100644 --- a/src/Tgstation.Server.Host/Components/IEventConsumer.cs +++ b/src/Tgstation.Server.Host/Components/IEventConsumer.cs @@ -7,7 +7,7 @@ namespace Tgstation.Server.Host.Components /// /// Consumes s and takes the appropriate actions /// - interface IEventConsumer + public interface IEventConsumer { /// /// Handle a given diff --git a/src/Tgstation.Server.Host/Components/IInstance.cs b/src/Tgstation.Server.Host/Components/IInstance.cs index 54cd4468d6..9868430342 100644 --- a/src/Tgstation.Server.Host/Components/IInstance.cs +++ b/src/Tgstation.Server.Host/Components/IInstance.cs @@ -8,7 +8,7 @@ namespace Tgstation.Server.Host.Components /// /// For interacting with the instance services /// - public interface IInstance : IHostedService, IReattachInfoHandler + public interface IInstance : IHostedService { /// /// The for the diff --git a/src/Tgstation.Server.Host/Components/IInstanceFactory.cs b/src/Tgstation.Server.Host/Components/IInstanceFactory.cs index d9d4f4cda8..ea9ed59f44 100644 --- a/src/Tgstation.Server.Host/Components/IInstanceFactory.cs +++ b/src/Tgstation.Server.Host/Components/IInstanceFactory.cs @@ -1,4 +1,5 @@ -using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Components.Watchdog; +using Tgstation.Server.Host.Core; namespace Tgstation.Server.Host.Components { @@ -10,8 +11,9 @@ namespace Tgstation.Server.Host.Components /// /// Create an /// - /// The + /// The + /// The for the /// A new - IInstance CreateInstance(Host.Models.Instance metadata); + IInstance CreateInstance(Models.Instance metadata, IInteropRegistrar interopRegistrar); } } \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Instance.cs b/src/Tgstation.Server.Host/Components/Instance.cs index b97f722e0c..d607053ab2 100644 --- a/src/Tgstation.Server.Host/Components/Instance.cs +++ b/src/Tgstation.Server.Host/Components/Instance.cs @@ -157,46 +157,5 @@ namespace Tgstation.Server.Host.Components timerTask = TimerLoop(newInterval.Value, timerCts.Token); } } - - /// - public Task Save(WatchdogReattachInformation reattachInformation, CancellationToken cancellationToken) => databaseContextFactory.UseContext(async (db) => - { - var instance = new Models.Instance { Id = metadata.Id }; - db.Instances.Attach(instance); - - Models.ReattachInformation ConvertReattachInfo(ReattachInformation wdInfo) - { - db.CompileJobs.Attach(wdInfo.Dmb.CompileJob); - return new Models.ReattachInformation - { - AccessIdentifier = wdInfo.AccessIdentifier, - ChatChannelsJson = wdInfo.ChatChannelsJson, - ChatCommandsJson = wdInfo.ChatCommandsJson, - CompileJob = wdInfo.Dmb.CompileJob, - IsPrimary = wdInfo.IsPrimary, - Port = wdInfo.Port, - ProcessId = wdInfo.ProcessId, - RebootState = wdInfo.RebootState - }; - } - - instance.WatchdogReattachInformation = new Models.WatchdogReattachInformation - { - Alpha = ConvertReattachInfo(reattachInformation.Alpha), - Bravo = ConvertReattachInfo(reattachInformation.Bravo), - AlphaIsActive = reattachInformation.AlphaIsActive, - }; - await db.Save(cancellationToken).ConfigureAwait(false); - }); - - /// - public async Task Load(CancellationToken cancellationToken) - { - Models.WatchdogReattachInformation result = null; - await databaseContextFactory.UseContext(async (db) => - result = await db.Instances.Where(x => x.Id == metadata.Id).Select(x => x.WatchdogReattachInformation).FirstAsync(cancellationToken).ConfigureAwait(false) - ).ConfigureAwait(false); - return new WatchdogReattachInformation(result, dmbFactory); - } } } diff --git a/src/Tgstation.Server.Host/Components/InstanceFactory.cs b/src/Tgstation.Server.Host/Components/InstanceFactory.cs index 7939caed1b..efbefbe737 100644 --- a/src/Tgstation.Server.Host/Components/InstanceFactory.cs +++ b/src/Tgstation.Server.Host/Components/InstanceFactory.cs @@ -1,5 +1,11 @@ -using System; +using Byond.TopicSender; +using Microsoft.Extensions.Logging; +using System; +using Tgstation.Server.Host.Components.Chat; +using Tgstation.Server.Host.Components.Chat.Commands; +using Tgstation.Server.Host.Components.Watchdog; using Tgstation.Server.Host.Core; +using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Components { @@ -16,22 +22,70 @@ namespace Tgstation.Server.Host.Components /// readonly IDatabaseContextFactory databaseContextFactory; + /// + /// The for the + /// + readonly IApplication application; + + /// + /// The for the + /// + readonly ILoggerFactory loggerFactory; + + /// + /// The for the + /// + readonly IByondTopicSender byondTopicSender; + + /// + /// The for the + /// + readonly IServerUpdater serverUpdater; + + /// + /// The for the + /// + readonly ICryptographySuite cryptographySuite; + + /// + /// The for the + /// + readonly IExecutor executor; + + /// + /// The for the + /// + readonly ICommandFactory commandFactory; + /// /// Construct an /// /// The value of /// The value of - public InstanceFactory(IIOManager ioManager, IDatabaseContextFactory databaseContextFactory) + /// The value of + /// The value of + /// The value of + /// The value of + /// The value of + /// The value of + /// The value of + public InstanceFactory(IIOManager ioManager, IDatabaseContextFactory databaseContextFactory, IApplication application, ILoggerFactory loggerFactory, IByondTopicSender byondTopicSender, IServerUpdater serverUpdater, ICryptographySuite cryptographySuite, IExecutor executor, ICommandFactory commandFactory) { this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.application = application ?? throw new ArgumentNullException(nameof(application)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender)); + this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater)); + this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite )); + this.executor = executor ?? throw new ArgumentNullException(nameof(executor)); + this.commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory)); } /// - public IInstance CreateInstance(Models.Instance metadata) + public IInstance CreateInstance(Models.Instance metadata, IInteropRegistrar interopRegistrar) { //Create the ioManager for the instance - var instanceIoManager = new ResolvingIOManager(ioManager, metadata.Path); //various other ioManagers @@ -42,10 +96,24 @@ namespace Tgstation.Server.Host.Components var codeModificationsIoMananger = new ResolvingIOManager(instanceIoManager, "CodeModifications"); var dmbFactory = new DmbFactory(databaseContextFactory, gameIoManager, metadata); + var commandFactory = new CommandFactory(application); + var chatFactory = new ChatFactory(instanceIoManager, loggerFactory, commandFactory); + var repoManager = new RepositoryManager(metadata.RepositorySettings, repoIoManager); + IByond byond = null; + IConfiguration configuration = null; + + var chat = chatFactory.CreateChat(); + var sessionControllerFactory = new SessionControllerFactory(executor, byond, byondTopicSender, interopRegistrar, cryptographySuite, application, gameIoManager, chat, loggerFactory, metadata); + var reattachInfoHandler = new ReattachInfoHandler(databaseContextFactory, dmbFactory, metadata); + var watchdogFactory = new WatchdogFactory(chat, sessionControllerFactory, serverUpdater, loggerFactory, reattachInfoHandler, databaseContextFactory, byondTopicSender, metadata); + var watchdog = watchdogFactory.CreateWatchdog(dmbFactory, metadata.DreamDaemonSettings); + + var dreamMaker = new DreamMaker(byond, ioManager, configuration, sessionControllerFactory, dmbFactory, application, watchdog, loggerFactory.CreateLogger()); throw new NotImplementedException(); + //return new Instance(metadata, repoManager, byond, dreamMaker, watchdog, chat, configuration, dmbFactory, databaseContextFactory, dmbFactory); } } } diff --git a/src/Tgstation.Server.Host/Components/InstanceManager.cs b/src/Tgstation.Server.Host/Components/InstanceManager.cs index ff78b157b8..24dd107c18 100644 --- a/src/Tgstation.Server.Host/Components/InstanceManager.cs +++ b/src/Tgstation.Server.Host/Components/InstanceManager.cs @@ -115,7 +115,7 @@ namespace Tgstation.Server.Host.Components { if (metadata == null) throw new ArgumentNullException(nameof(metadata)); - var instance = instanceFactory.CreateInstance(metadata); + var instance = instanceFactory.CreateInstance(metadata, this); lock (this) { if (instances.ContainsKey(metadata.Id)) diff --git a/src/Tgstation.Server.Host/Components/ReattachInfoHandler.cs b/src/Tgstation.Server.Host/Components/ReattachInfoHandler.cs new file mode 100644 index 0000000000..93c7e6aa91 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/ReattachInfoHandler.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Watchdog; +using Tgstation.Server.Host.Core; + +namespace Tgstation.Server.Host.Components +{ + /// + sealed class ReattachInfoHandler: IReattachInfoHandler + { + /// + /// The for the + /// + readonly IDatabaseContextFactory databaseContextFactory; + + /// + /// The for the + /// + readonly IDmbFactory dmbFactory; + + /// + /// The for the + /// + readonly Models.Instance metadata; + + /// + /// Construct a + /// + /// The value of + /// The value of + /// The value of + public ReattachInfoHandler(IDatabaseContextFactory databaseContextFactory, IDmbFactory dmbFactory, Models.Instance metadata) + { + this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.dmbFactory = dmbFactory ?? throw new ArgumentNullException(nameof(dmbFactory)); + this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata)); + } + + /// + public Task Save(WatchdogReattachInformation reattachInformation, CancellationToken cancellationToken) => databaseContextFactory.UseContext(async (db) => + { + var instance = new Models.Instance { Id = metadata.Id }; + db.Instances.Attach(instance); + + Models.ReattachInformation ConvertReattachInfo(ReattachInformation wdInfo) + { + db.CompileJobs.Attach(wdInfo.Dmb.CompileJob); + return new Models.ReattachInformation + { + AccessIdentifier = wdInfo.AccessIdentifier, + ChatChannelsJson = wdInfo.ChatChannelsJson, + ChatCommandsJson = wdInfo.ChatCommandsJson, + CompileJob = wdInfo.Dmb.CompileJob, + IsPrimary = wdInfo.IsPrimary, + Port = wdInfo.Port, + ProcessId = wdInfo.ProcessId, + RebootState = wdInfo.RebootState + }; + } + + instance.WatchdogReattachInformation = new Models.WatchdogReattachInformation + { + Alpha = ConvertReattachInfo(reattachInformation.Alpha), + Bravo = ConvertReattachInfo(reattachInformation.Bravo), + AlphaIsActive = reattachInformation.AlphaIsActive, + }; + await db.Save(cancellationToken).ConfigureAwait(false); + }); + + /// + public async Task Load(CancellationToken cancellationToken) + { + Models.WatchdogReattachInformation result = null; + await databaseContextFactory.UseContext(async (db) => + result = await db.Instances.Where(x => x.Id == metadata.Id).Select(x => x.WatchdogReattachInformation).FirstAsync(cancellationToken).ConfigureAwait(false) + ).ConfigureAwait(false); + return new WatchdogReattachInformation(result, dmbFactory); + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Watchdog/IWatchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/IWatchdog.cs index 99e19e7ef0..c7533c58ca 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/IWatchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/IWatchdog.cs @@ -9,7 +9,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// Runs and monitors the twin server controllers /// - public interface IWatchdog : IHostedService, IDisposable + public interface IWatchdog : IHostedService, IDisposable, IEventConsumer { /// /// If the watchdog is running diff --git a/src/Tgstation.Server.Host/Components/Watchdog/SessionControllerFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/SessionControllerFactory.cs index 4f2dc78885..bf472fcd33 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/SessionControllerFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/SessionControllerFactory.cs @@ -47,11 +47,6 @@ namespace Tgstation.Server.Host.Components.Watchdog /// readonly IApplication application; - /// - /// The for the - /// - readonly IInstance instance; - /// /// The for the /// @@ -67,6 +62,11 @@ namespace Tgstation.Server.Host.Components.Watchdog /// readonly ILoggerFactory loggerFactory; + /// + /// The for the + /// + readonly Models.Instance instance; + /// /// Construct a /// @@ -80,7 +80,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The value of /// The value of /// The value of - public SessionControllerFactory(IExecutor executor, IByond byond, IByondTopicSender byondTopicSender, IInteropRegistrar interopRegistrar, ICryptographySuite cryptographySuite, IApplication application, IInstance instance, IIOManager ioManager, IChat chat, ILoggerFactory loggerFactory) + public SessionControllerFactory(IExecutor executor, IByond byond, IByondTopicSender byondTopicSender, IInteropRegistrar interopRegistrar, ICryptographySuite cryptographySuite, IApplication application, IIOManager ioManager, IChat chat, ILoggerFactory loggerFactory, Models.Instance instance) { this.executor = executor ?? throw new ArgumentNullException(nameof(executor)); this.byond = byond ?? throw new ArgumentNullException(nameof(byond)); @@ -111,7 +111,7 @@ namespace Tgstation.Server.Host.Components.Watchdog ChatChannelsJson = GuidJsonFile(), ChatCommandsJson = GuidJsonFile(), HostPath = application.HostingPath, - InstanceName = instance.GetMetadata().Name, + InstanceName = instance.Name, Revision = dmbProvider.CompileJob.RevisionInformation }; interopInfo.TestMerges.AddRange(dmbProvider.CompileJob.RevisionInformation.TestMerges.Select(x => new TestMerge @@ -141,7 +141,7 @@ namespace Tgstation.Server.Host.Components.Watchdog var chatJsonTrackingContext = await chatJsonTrackingTask.ConfigureAwait(false); try { - var byondLock = currentByondLock ?? byond.UseExecutables(dmbProvider.CompileJob.ByondVersion); + var byondLock = currentByondLock ?? byond.UseExecutables(Version.Parse(dmbProvider.CompileJob.ByondVersion)); try { //more sanitization here cause it uses the same scheme @@ -189,7 +189,7 @@ namespace Tgstation.Server.Host.Components.Watchdog var chatJsonTrackingContext = await chat.TrackJsons(basePath, reattachInformation.ChatChannelsJson, reattachInformation.ChatCommandsJson, cancellationToken).ConfigureAwait(false); try { - var byondLock = byond.UseExecutables(reattachInformation.Dmb.CompileJob.ByondVersion); + var byondLock = byond.UseExecutables(Version.Parse(reattachInformation.Dmb.CompileJob.ByondVersion)); try { var session = executor.AttachToDreamDaemon(reattachInformation.ProcessId, byondLock); diff --git a/src/Tgstation.Server.Host/Components/Watchdog/Watchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/Watchdog.cs index c64d6212c7..6129f45488 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/Watchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/Watchdog.cs @@ -15,7 +15,7 @@ using Tgstation.Server.Host.Core; namespace Tgstation.Server.Host.Components.Watchdog { /// - sealed class Watchdog : IWatchdog, IEventConsumer, ICustomCommandHandler + sealed class Watchdog : IWatchdog, ICustomCommandHandler { /// /// The time in milliseconds to wait from starting to start . Does not take responsiveness into account diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index 963b22e98a..ae4489d387 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authentication.JwtBearer; +using Byond.TopicSender; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server.Features; @@ -14,6 +15,9 @@ using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Reflection; using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Components.Chat; +using Tgstation.Server.Host.Components.Chat.Commands; +using Tgstation.Server.Host.Components.Watchdog; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Controllers; using Tgstation.Server.Host.Models; @@ -30,6 +34,9 @@ namespace Tgstation.Server.Host.Core /// public Version Version { get; } + /// + public string VersionString { get; } + /// /// The for the /// @@ -56,6 +63,7 @@ namespace Tgstation.Server.Host.Core this.hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment)); Version = Assembly.GetExecutingAssembly().GetName().Version; + VersionString = String.Format(CultureInfo.InvariantCulture, "/tg/station server v{0}", Version); } /// @@ -137,9 +145,17 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); services.AddSingleton(); - services.AddSingleton, PasswordHasher>(); + services.AddSingleton, PasswordHasher>(); services.AddSingleton(); services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(new ByondTopicSender + { + ReceiveTimeout = 5000, + SendTimeout = 5000 + }); services.AddSingleton(); services.AddSingleton(x => x.GetRequiredService()); diff --git a/src/Tgstation.Server.Host/Core/IApplication.cs b/src/Tgstation.Server.Host/Core/IApplication.cs index eed6769ab3..b00ae8a3b9 100644 --- a/src/Tgstation.Server.Host/Core/IApplication.cs +++ b/src/Tgstation.Server.Host/Core/IApplication.cs @@ -7,6 +7,11 @@ namespace Tgstation.Server.Host.Core /// public interface IApplication { + /// + /// A more verbose version of + /// + string VersionString { get; } + /// /// The version of the /// diff --git a/src/Tgstation.Server.Host/Models/CompileJob.cs b/src/Tgstation.Server.Host/Models/CompileJob.cs index 6245765298..1fbcc9f498 100644 --- a/src/Tgstation.Server.Host/Models/CompileJob.cs +++ b/src/Tgstation.Server.Host/Models/CompileJob.cs @@ -1,4 +1,5 @@ -using System.ComponentModel.DataAnnotations; +using System; +using System.ComponentModel.DataAnnotations; using Tgstation.Server.Api.Models; namespace Tgstation.Server.Host.Models @@ -23,6 +24,11 @@ namespace Tgstation.Server.Host.Models [Required] public RevisionInformation RevisionInformation { get; set; } + /// + /// The the was made with in string form + /// + public string ByondVersion { get; set; } + /// public Api.Models.CompileJob ToApi() => new Api.Models.CompileJob { @@ -33,7 +39,8 @@ namespace Tgstation.Server.Host.Models Id = Id, Job = Job.ToApi(), Output = Output, - RevisionInformation = RevisionInformation.ToApi() + RevisionInformation = RevisionInformation.ToApi(), + ByondVersion = Version.Parse(ByondVersion) }; } } diff --git a/src/Tgstation.Server.Host/Models/DatabaseContext.cs b/src/Tgstation.Server.Host/Models/DatabaseContext.cs index ff966eba6f..1aa4170164 100644 --- a/src/Tgstation.Server.Host/Models/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Models/DatabaseContext.cs @@ -121,6 +121,7 @@ namespace Tgstation.Server.Host.Models var chatChannel = modelBuilder.Entity(); chatChannel.HasIndex(x => new { x.ChatSettingsId, x.IrcChannel }).IsUnique(); chatChannel.HasIndex(x => new { x.ChatSettingsId, x.DiscordChannelId }).IsUnique(); + chatChannel.HasOne(x => x.ChatSettings).WithMany(x => x.Channels).HasForeignKey(x => x.ChatSettingsId).OnDelete(DeleteBehavior.Cascade); } /// diff --git a/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs index 519d590b0d..22833bacdb 100644 --- a/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs +++ b/tests/Tgstation.Server.Host.Tests/Core/TestApplication.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; @@ -13,7 +14,7 @@ namespace Tgstation.Server.Host.Core.Tests public void ApplyUpdate(string updatePath) => throw new NotImplementedException(); public void RegisterForUpdate(Action action) => throw new NotImplementedException(); - + [TestMethod] public async Task TestSuccessfulStartup() { @@ -22,6 +23,7 @@ namespace Tgstation.Server.Host.Core.Tests { using (var webHost = WebHost.CreateDefaultBuilder(new string[] { "Database:DatabaseType=Sqlite", "Database:ConnectionString=Data Source=" + dbName }) //force it to use sqlite .UseStartup() + .ConfigureServices((serviceCollection) => serviceCollection.AddSingleton(this)) .Build() ) {