diff --git a/UpgradeLog.htm b/UpgradeLog.htm new file mode 100644 index 0000000000..1b7f8648c7 Binary files /dev/null and b/UpgradeLog.htm differ diff --git a/src/DMAPI/tgs.dm b/src/DMAPI/tgs.dm index eee991a8aa..c3daf1a380 100644 --- a/src/DMAPI/tgs.dm +++ b/src/DMAPI/tgs.dm @@ -44,8 +44,8 @@ //EVENT CODES -#define TGS_EVENT_PORT_SWAP 1 //before a port change is about to happen, extra parameter is new port -#define TGS_EVENT_REBOOT_MODE_CHANGE 2 //before a reboot mode change, extras parameters are the current and new reboot mode enums +#define TGS_EVENT_PORT_SWAP -2 //before a port change is about to happen, extra parameter is new port +#define TGS_EVENT_REBOOT_MODE_CHANGE -1 //before a reboot mode change, extras parameters are the current and new reboot mode enums //OTHER ENUMS @@ -97,8 +97,7 @@ /datum/tgs_chat_channel var/id //internal channel representation var/friendly_name //user friendly channel name - var/server_name //server name the channel resides on - var/provider_name //chat provider for the channel + var/connection_name //the name of the configured chat connection var/is_admin_channel //if the server operator has marked this channel for game admins only var/is_private_channel //if this is a private chat channel diff --git a/src/DMAPI/tgs/v4/api.dm b/src/DMAPI/tgs/v4/api.dm index cce546d7c4..a6700ab4e5 100644 --- a/src/DMAPI/tgs/v4/api.dm +++ b/src/DMAPI/tgs/v4/api.dm @@ -203,14 +203,16 @@ //no caching cause tgs may change this var/list/json = json_decode(file2text(chat_channels_json_path)) for(var/I in json) - var/datum/tgs_chat_channel/channel = new - channel.id = I["id"] - channel.friendly_name = I["friendly_name"] - channel.server_name = I["server_name"] - channel.provider_name = I["provider_name"] - channel.is_admin_channel = I["is_admin_channel"] - channel.is_private_channel = FALSE //tgs will never send us pm channels - . += channel + . += DecodeChannel(I) + +/datum/tgs_api/v4/proc/DecodeChannel(channel_json) + var/datum/tgs_chat_channel/channel = new + channel.id = channel_json["id"] + 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 + return channel #undef TGS4_TOPIC_COMMAND #undef TGS4_TOPIC_TOKEN diff --git a/src/DMAPI/tgs/v4/commands.dm b/src/DMAPI/tgs/v4/commands.dm index 6034affe19..48ca6e98de 100644 --- a/src/DMAPI/tgs/v4/commands.dm +++ b/src/DMAPI/tgs/v4/commands.dm @@ -30,14 +30,7 @@ u.id = user["id"] u.friendly_name = user["friendly_name"] u.mention = user["mention"] - var/datum/tgs_chat_channel/channel = new - u.channel = channel - var/channel_json = user["channel"] - channel.id = channel_json["id"] - channel.friendly_name = channel_json["friendly_name"] - channel.server_name = channel_json["server_name"] - channel.is_admin_channel = channel_json["is_admin_channel"] - channel.is_private_channel = channel_json["is_private_channel"] + u.channel = DecodeChannel(user["channel"]) var/datum/tgs_chat_command/sc = custom_commands[command] var/result = sc.Run(u, params) diff --git a/src/Tgstation.Server.Api/Models/ChatChannel.cs b/src/Tgstation.Server.Api/Models/ChatChannel.cs index a927090029..942154e506 100644 --- a/src/Tgstation.Server.Api/Models/ChatChannel.cs +++ b/src/Tgstation.Server.Api/Models/ChatChannel.cs @@ -13,11 +13,16 @@ /// /// The Discord channel ID /// - public long DiscordChannelId { get; set; } + public long? DiscordChannelId { get; set; } /// /// If the is an admin channel /// public bool IsAdminChannel { get; set; } + + /// + /// If the is a watchdog channel + /// + public bool IsWatchdogChannel { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/ChatProvider.cs b/src/Tgstation.Server.Api/Models/ChatProvider.cs new file mode 100644 index 0000000000..f41d9de9c5 --- /dev/null +++ b/src/Tgstation.Server.Api/Models/ChatProvider.cs @@ -0,0 +1,17 @@ +namespace Tgstation.Server.Api.Models +{ + /// + /// Represents a chat service provider + /// + public enum ChatProvider + { + /// + /// Internet relay chat + /// + Irc, + /// + /// Superior chat service + /// + Discord + } +} diff --git a/src/Tgstation.Server.Api/Models/ChatSettings.cs b/src/Tgstation.Server.Api/Models/ChatSettings.cs index 2900a7e055..aab5431e9a 100644 --- a/src/Tgstation.Server.Api/Models/ChatSettings.cs +++ b/src/Tgstation.Server.Api/Models/ChatSettings.cs @@ -6,22 +6,10 @@ namespace Tgstation.Server.Api.Models /// public sealed class ChatSettings : Internal.ChatSettings { - /// - /// If the IRC connection is established - /// - [Permissions(DenyWrite = true)] - bool IrcConnected { get; set; } - - /// - /// If the Discord connection is established - /// - [Permissions(DenyWrite = true)] - bool DiscordConnected { get; set; } - /// /// Channels the Discord bot should listen/announce in /// - [Permissions(WriteRight = ChatSettingsRights.SetChannels)] + [Permissions(WriteRight = ChatSettingsRights.WriteChannels)] public List Channels { get; set; } } } diff --git a/src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs b/src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs index a672895d3c..2a23052ad8 100644 --- a/src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs +++ b/src/Tgstation.Server.Api/Models/Internal/ChatSettings.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; using Tgstation.Server.Api.Rights; namespace Tgstation.Server.Api.Models.Internal @@ -8,44 +6,39 @@ namespace Tgstation.Server.Api.Models.Internal /// /// Manage the server chat bots /// - [Model(RightsType.ChatSettings, RequiresInstance = true)] + [Model(RightsType.ChatSettings, RequiresInstance = true, CanList = true, CanCrud = true, ReadRight = ChatSettingsRights.Read)] public class ChatSettings { /// - /// If the IRC client is enabled + /// The settings id /// - [Permissions(WriteRight = ChatSettingsRights.SetIrcEnabled)] - public bool IrcEnabled { get; set; } + [Permissions(DenyWrite = true)] + public long Id { get; set; } /// - /// The IRC server name + /// The name of the connection /// - [Permissions(ReadRight = ChatSettingsRights.SetIrcSettings, WriteRight = ChatSettingsRights.SetIrcSettings)] + [Permissions(WriteRight = ChatSettingsRights.WriteName)] [Required] - public string IrcHost { get; set; } + public string Name { get; set; } /// - /// The IRC server port + /// If the connection is enabled /// - [Permissions(ReadRight = ChatSettingsRights.SetIrcSettings, WriteRight = ChatSettingsRights.SetIrcSettings)] - public ushort IrcPort { get; set; } + [Permissions(WriteRight = ChatSettingsRights.WriteEnabled)] + public bool? Enabled { get; set; } /// - /// The IRC server NickServ password + /// The used for the connection /// - [Permissions(ReadRight = ChatSettingsRights.SetIrcSettings, WriteRight = ChatSettingsRights.SetIrcSettings)] - public string IrcNickServPassword { get; set; } + [Permissions(WriteRight = ChatSettingsRights.WriteProvider)] + public ChatProvider? Provider { get; set; } /// - /// If the Discord bot is enabled + /// The information used to connect to the /// - [Permissions(WriteRight = ChatSettingsRights.SetDiscordEnabled)] - public bool DiscordEnabled { get; set; } - - /// - /// The Discord bot token - /// - [Permissions(ReadRight = ChatSettingsRights.SetDiscordSettings, WriteRight = ChatSettingsRights.SetDiscordSettings)] - public string DiscordBotToken { get; set; } + [Permissions(ReadRight = ChatSettingsRights.ReadConnectionString, WriteRight = ChatSettingsRights.ReadConnectionString)] + [Required] + public string ConnectionString { get; set; } } } diff --git a/src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs b/src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs index d27723c28c..c4336d76dd 100644 --- a/src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs +++ b/src/Tgstation.Server.Api/Rights/ChatSettingsRights.cs @@ -13,24 +13,40 @@ namespace Tgstation.Server.Api.Rights /// None = 0, /// - /// User can enable/disable the IRC client + /// User can change /// - SetIrcEnabled = 1, + WriteEnabled = 1, /// - /// User can change the IRC settings + /// User can change /// - SetIrcSettings = 2, + WriteProvider = 2, /// - /// User can change the chat channels + /// User can change /// - SetChannels = 4, + WriteChannels = 4, /// - /// User can enable/disable the Discord bot + /// User can change /// - SetDiscordEnabled = 8, + WriteConnectionString = 8, /// - /// User can change the Discord settings + /// User can read /// - SetDiscordSettings = 16, + ReadConnectionString = 16, + /// + /// User can read all chat settings except + /// + Read = 32, + /// + /// User can change + /// + WriteName = 32, + /// + /// User can create new + /// + Create = 64, + /// + /// User can delete + /// + Delete = 128 } } diff --git a/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj b/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj index 157f22838f..70e1a609ed 100644 --- a/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj +++ b/src/Tgstation.Server.Host.Watchdog/Tgstation.Server.Host.Watchdog.csproj @@ -18,7 +18,6 @@ - diff --git a/src/Tgstation.Server.Host/Components/Chat/Channel.cs b/src/Tgstation.Server.Host/Components/Chat/Channel.cs new file mode 100644 index 0000000000..c2eab84dd8 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Channel.cs @@ -0,0 +1,34 @@ +namespace Tgstation.Server.Host.Components.Chat +{ + /// + /// Represents a channel + /// + public sealed class Channel + { + /// + /// The channel Id. + /// + /// remaps this to an internal id using + public long Id { get; set; } + + /// + /// The user friendly name of the + /// + public string FriendlyName { get; set; } + + /// + /// The name of the connection the belongs to + /// + public string ConnectionName { get; set; } + + /// + /// If this is considered a channel for admin commands + /// + public bool IsAdmin { get; set; } + + /// + /// If this is a 1-to-1 chat channel + /// + public bool IsPrivate { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/ChannelMapping.cs b/src/Tgstation.Server.Host/Components/Chat/ChannelMapping.cs new file mode 100644 index 0000000000..243bf71206 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/ChannelMapping.cs @@ -0,0 +1,11 @@ +namespace Tgstation.Server.Host.Components.Chat +{ + sealed class ChannelMapping + { + public long ProviderId { get; set; } + public long ProviderChannelId { get; set; } + public bool IsWatchdogChannel { get; set; } + + 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 new file mode 100644 index 0000000000..07a29865c9 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Chat.cs @@ -0,0 +1,268 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Api.Models.Internal; +using Tgstation.Server.Host.Components.Chat.Commands; +using Tgstation.Server.Host.Components.Chat.Providers; +using Tgstation.Server.Host.Core; + +namespace Tgstation.Server.Host.Components.Chat +{ + /// + sealed class Chat : IChat + { + /// + /// The for the + /// + readonly IProviderFactory providerFactory; + + /// + /// The for the + /// + readonly IIOManager ioManager; + + /// + /// s that never change + /// + readonly IReadOnlyList builtinCommands; + + /// + /// Map of s in use, keyed by + /// + readonly Dictionary providers; + + /// + /// Map of s to s + /// + readonly Dictionary mappedChannels; + + /// + /// The active s for the + /// + readonly List trackingContexts; + + /// + /// The for the + /// + ICustomCommandHandler customCommandHandler; + + /// + /// Used for remapping s + /// + long channelIdCounter; + + /// + /// If has been called + /// + bool started; + + /// + /// Construct a + /// + /// The value of + /// The value of + /// The used to populate + public Chat(IProviderFactory providerFactory, IIOManager ioManager, 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)); + + providers = new Dictionary(); + mappedChannels = new Dictionary(); + trackingContexts = new List(); + channelIdCounter = 1; + } + + /// + public void Dispose() + { + foreach (var I in providers) + I.Value.Dispose(); + } + + /// + /// Remove a from and optionally updating the as well + /// + /// The of the to delete + /// If should be update + /// The for the operation + /// A resulting in the being removed if it exists, otherwise + async Task RemoveProvider(long connectionId, bool updateTrackings, CancellationToken cancellationToken) + { + IProvider provider; + lock (providers) + if (!providers.TryGetValue(connectionId, out provider)) + return null; + Task task; + lock (mappedChannels) + { + foreach (var kvp in mappedChannels.Where(x => x.Value.ProviderId == connectionId)) + mappedChannels.Remove(kvp.Key); + + if (updateTrackings) + lock (trackingContexts) + task = Task.WhenAll(trackingContexts.Select(x => x.SetChannels(mappedChannels.Select(y => y.Value.Channel), cancellationToken))); + else + task = Task.CompletedTask; + } + await task.ConfigureAwait(false); + return provider; + } + + /// + public async Task ChangeChannels(long connectionId, IEnumerable newChannels, CancellationToken cancellationToken) + { + if (newChannels == null) + throw new ArgumentNullException(nameof(newChannels)); + var provider = await RemoveProvider(connectionId, false, cancellationToken).ConfigureAwait(false); + if (provider == null) + return; + var results = await provider.MapChannels(newChannels, cancellationToken).ConfigureAwait(false); + if (results == null) //aborted + return; + var mappings = Enumerable.Zip(newChannels, results, (x, y) => new ChannelMapping + { + IsWatchdogChannel = x.IsWatchdogChannel, + ProviderChannelId = y.Id, + ProviderId = connectionId, + Channel = y + }); + + long baseId; + lock (this) + { + baseId = channelIdCounter; + channelIdCounter += results.Count; + } + + Task task; + lock (mappedChannels) + { + lock (providers) + if (!providers.TryGetValue(connectionId, out IProvider verify) || verify != provider) //aborted again + return; + foreach (var I in mappings) + { + var newId = baseId++; + mappedChannels.Add(newId, I); + I.Channel.Id = newId; + } + + lock (trackingContexts) + task = Task.WhenAll(trackingContexts.Select(x => x.SetChannels(mappedChannels.Select(y => y.Value.Channel), cancellationToken))); + } + await task.ConfigureAwait(false); + } + + /// + public async Task ChangeSettings(ChatSettings newSettings, CancellationToken cancellationToken) + { + if (newSettings == null) + throw new ArgumentNullException(nameof(newSettings)); + IProvider provider; + lock (providers) + { + //raw settings changes forces a rebuild of the provider + if (providers.TryGetValue(newSettings.Id, out provider)) + { + providers.Remove(newSettings.Id); + provider.Dispose(); + } + if (newSettings.Enabled.Value) + { + provider = providerFactory.CreateProvider(newSettings); + providers.Add(newSettings.Id, provider); + } + } + lock (mappedChannels) + foreach (var channelId in mappedChannels.Where(x => x.Value.ProviderId == newSettings.Id).Select(x => x.Key)) + mappedChannels.Remove(channelId); + if (newSettings.Enabled.Value && started) + await provider.Connect(cancellationToken).ConfigureAwait(false); + } + + /// + public Task SendMessage(string message, IEnumerable channelIds, CancellationToken cancellationToken) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (channelIds == null) + throw new ArgumentNullException(nameof(channelIds)); + + return Task.WhenAll(channelIds.Select(x => + { + ChannelMapping channelMapping; + lock(mappedChannels) + if (!mappedChannels.TryGetValue(x, out channelMapping)) + return Task.CompletedTask; + IProvider provider; + lock (providers) + if (!providers.TryGetValue(channelMapping.ProviderId, out provider)) + return Task.CompletedTask; + return provider.SendMessage(channelMapping.ProviderChannelId, message, cancellationToken); + })); + } + + /// + public Task SendWatchdogMessage(string message, CancellationToken cancellationToken) + { + 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); + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Connect(cancellationToken))).ConfigureAwait(false); + started = true; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Disconnect(cancellationToken))); + + /// + public async Task TrackJsons(string basePath, string channelsJsonName, string commandsJsonName, CancellationToken cancellationToken) + { + if (customCommandHandler == null) + throw new InvalidOperationException("RegisterCommandHandler() hasn't been called!"); + JsonTrackingContext context = null; + context = new JsonTrackingContext(ioManager, customCommandHandler, () => + { + lock (trackingContexts) + trackingContexts.Remove(context); + }, ioManager.ConcatPath(basePath, commandsJsonName), ioManager.ConcatPath(basePath, channelsJsonName)); + Task task; + lock (trackingContexts) + { + trackingContexts.Add(context); + lock (mappedChannels) + task = Task.WhenAll(trackingContexts.Select(x => x.SetChannels(mappedChannels.Select(y => y.Value.Channel), cancellationToken))); + } + await task.ConfigureAwait(false); + return context; + } + + /// + public bool Connected(long connectionId) + { + lock (providers) + return providers.TryGetValue(connectionId, out var provider) && provider.Connected; + } + + /// + public void RegisterCommandHandler(ICustomCommandHandler customCommandHandler) + { + if (this.customCommandHandler != null) + throw new InvalidOperationException("RegisterCommandHandler() already called!"); + this.customCommandHandler = customCommandHandler ?? throw new ArgumentNullException(nameof(customCommandHandler)); + } + + /// + public Task DeleteConnection(long connectionId, CancellationToken cancellationToken) => RemoveProvider(connectionId, true, cancellationToken); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/ChatResponse.cs b/src/Tgstation.Server.Host/Components/Chat/ChatResponse.cs deleted file mode 100644 index e7bc242642..0000000000 --- a/src/Tgstation.Server.Host/Components/Chat/ChatResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; - -namespace Tgstation.Server.Host.Components.Chat -{ - sealed class ChatResponse - { - public string Message { get; set; } - public List ChannelIds { get; set; } - } -} diff --git a/src/Tgstation.Server.Host/Components/Chat/Commands/Command.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/Command.cs new file mode 100644 index 0000000000..d5f192ec59 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/Command.cs @@ -0,0 +1,29 @@ +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/CustomCommand.cs b/src/Tgstation.Server.Host/Components/Chat/Commands/CustomCommand.cs new file mode 100644 index 0000000000..6221f531c1 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Commands/CustomCommand.cs @@ -0,0 +1,33 @@ +using System; + +namespace Tgstation.Server.Host.Components.Chat.Commands +{ + /// + /// Represents a command made from DM code + /// + public sealed class CustomCommand : Command + { + /// + /// The for the + /// + ICustomCommandHandler handler; + + /// + /// Set a new + /// + /// The value of + public void SetHandler(ICustomCommandHandler handler) + { + if (this.handler != null) + throw new InvalidOperationException("SetHandler() already called!"); + this.handler = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + /// + public override void Invoke(string arguments) + { + if (handler == null) + throw new InvalidOperationException("SetHandler() has not been called!"); + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/IChat.cs b/src/Tgstation.Server.Host/Components/Chat/IChat.cs index f73805eb2b..68c12aa1ed 100644 --- a/src/Tgstation.Server.Host/Components/Chat/IChat.cs +++ b/src/Tgstation.Server.Host/Components/Chat/IChat.cs @@ -10,33 +10,45 @@ namespace Tgstation.Server.Host.Components.Chat /// /// For managing connected chat services /// - public interface IChat : IHostedService + public interface IChat : IHostedService, IDisposable { /// - /// If the IRC client is connected + /// If a given set of is connected /// - bool IrcConnected { get; } + /// The of the connection + /// if it is connected, otherwise + bool Connected(long connectionId); /// - /// If the Discord client is connected + /// Registers a to use /// - bool DiscordConnected { get; } + /// A + void RegisterCommandHandler(ICustomCommandHandler customCommandHandler); /// - /// Change chat settings + /// Change chat settings. If the is not currently in use, a new connection will be made instead /// /// The new /// The for the operation - /// A representing the running operation + /// A representing the running operation. Will complete immediately if the property of is Task ChangeSettings(ChatSettings newSettings, CancellationToken cancellationToken); + /// + /// Disconnects and deletes a given connection + /// + /// The of the connection + /// The for the operation + /// A representing the running operation + Task DeleteConnection(long connectionId, CancellationToken cancellationToken); + /// /// Change chat channels /// + /// The of the connection /// An of the new list of s /// The for the operation /// A representing the running operation - Task ChangeChannels(IEnumerable newChannels, CancellationToken cancellationToken); + Task ChangeChannels(long connectionId, IEnumerable newChannels, CancellationToken cancellationToken); /// /// Send a chat to a given set of @@ -63,6 +75,6 @@ namespace Tgstation.Server.Host.Components.Chat /// The name of the chat commands json /// The for the operation /// A resulting in a tied to the lifetime of the json trackings - Task TrackJsons(string basePath, string channelsJsonName, string commandsJsonName, CancellationToken cancellationToken); + Task TrackJsons(string basePath, string channelsJsonName, string commandsJsonName, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Chat/IChatJsonTrackingContext.cs b/src/Tgstation.Server.Host/Components/Chat/IChatJsonTrackingContext.cs deleted file mode 100644 index 8926b33291..0000000000 --- a/src/Tgstation.Server.Host/Components/Chat/IChatJsonTrackingContext.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace Tgstation.Server.Host.Components.Chat -{ - /// - /// Represents a tracking of dynamic chat json files - /// - public interface IChatJsonTrackingContext : IDisposable - { - } -} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Chat/ICommandFactory.cs b/src/Tgstation.Server.Host/Components/Chat/ICommandFactory.cs new file mode 100644 index 0000000000..4020ab0613 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/ICommandFactory.cs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000000..5106ba0efd --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/ICustomCommandHandler.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Components.Chat +{ + /// + /// Handles that map to those defined in a + /// + public interface ICustomCommandHandler + { + /// + /// Handle a chat command + /// + /// The command name + /// Everything typed after minus leading spaces + /// The sending + /// The for the operation + /// A resulting in the response text to send back + Task HandleChatCommand(string commandName, string arguments, User sender, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Chat/IJsonTrackingContext.cs b/src/Tgstation.Server.Host/Components/Chat/IJsonTrackingContext.cs new file mode 100644 index 0000000000..778b6d0d3c --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/IJsonTrackingContext.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Chat.Commands; + +namespace Tgstation.Server.Host.Components.Chat +{ + /// + /// Represents a tracking of dynamic chat json files + /// + public interface IJsonTrackingContext : IDisposable + { + /// + /// Read s from the + /// + /// The for the operation + /// A resulting in a of s in the + Task> GetCustomCommands(CancellationToken cancellationToken); + + /// + /// Writes information about connected to the + /// + /// The s to write out + /// The for the operation + /// A representing the running operation + Task SetChannels(IEnumerable channels, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Chat/IProviderFactory.cs b/src/Tgstation.Server.Host/Components/Chat/IProviderFactory.cs new file mode 100644 index 0000000000..b4b47f2d5b --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/IProviderFactory.cs @@ -0,0 +1,18 @@ +using Tgstation.Server.Api.Models.Internal; +using Tgstation.Server.Host.Components.Chat.Providers; + +namespace Tgstation.Server.Host.Components.Chat +{ + /// + /// Factory for s + /// + interface IProviderFactory + { + /// + /// Create a + /// + /// The for the new provider + /// A new + IProvider CreateProvider(ChatSettings settings); + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/JsonTrackingContext.cs b/src/Tgstation.Server.Host/Components/Chat/JsonTrackingContext.cs new file mode 100644 index 0000000000..c231ea3ef7 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/JsonTrackingContext.cs @@ -0,0 +1,63 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Tgstation.Server.Host.Components.Chat.Commands; +using Tgstation.Server.Host.Core; + +namespace Tgstation.Server.Host.Components.Chat +{ + /// + sealed class JsonTrackingContext : IJsonTrackingContext + { + readonly IIOManager ioManager; + readonly ICustomCommandHandler customCommandHandler; + readonly Action onDispose; + + readonly string commandsPath; + readonly string channelsPath; + + readonly SemaphoreSlim channelsSemaphore; + + public JsonTrackingContext(IIOManager ioManager, ICustomCommandHandler customCommandHandler, Action onDispose, string commandsPath, string channelsPath) + { + this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); + this.customCommandHandler = customCommandHandler ?? throw new ArgumentNullException(nameof(customCommandHandler)); + this.onDispose = onDispose ?? throw new ArgumentNullException(nameof(onDispose)); + this.commandsPath = commandsPath ?? throw new ArgumentNullException(nameof(commandsPath)); + this.channelsPath = channelsPath ?? throw new ArgumentNullException(nameof(channelsPath)); + + channelsSemaphore = new SemaphoreSlim(1); + } + + /// + public void Dispose() => onDispose(); + + /// + public async Task> GetCustomCommands(CancellationToken cancellationToken) + { + try + { + var resultBytes = await ioManager.ReadAllBytes(commandsPath, cancellationToken).ConfigureAwait(false); + var resultJson = Encoding.UTF8.GetString(resultBytes); + var result = JsonConvert.DeserializeObject>(resultJson); + foreach (var I in result) + I.SetHandler(customCommandHandler); + return result; + } + catch + { + return new List(); + } + } + + /// + public async Task SetChannels(IEnumerable channels, CancellationToken cancellationToken) + { + using (await SemaphoreSlimContext.Lock(channelsSemaphore, cancellationToken).ConfigureAwait(false)) + await ioManager.WriteAllBytes(channelsPath, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(channels)), cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Message.cs b/src/Tgstation.Server.Host/Components/Chat/Message.cs new file mode 100644 index 0000000000..e9b90e6560 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Message.cs @@ -0,0 +1,8 @@ +namespace Tgstation.Server.Host.Components.Chat.Providers +{ + sealed class Message + { + string Content { get; set; } + User User { get; set; } + } +} \ No newline at end of file diff --git a/src/Tgstation.Server.Host/Components/Chat/ProviderFactory.cs b/src/Tgstation.Server.Host/Components/Chat/ProviderFactory.cs new file mode 100644 index 0000000000..b16cd3617c --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/ProviderFactory.cs @@ -0,0 +1,27 @@ +using System; +using System.Globalization; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Components.Chat.Providers; + +namespace Tgstation.Server.Host.Components.Chat +{ + /// + sealed class ProviderFactory : IProviderFactory + { + /// + public IProvider CreateProvider(Api.Models.Internal.ChatSettings settings) + { + if (settings == null) + throw new ArgumentNullException(nameof(settings)); + switch (settings.Provider) + { + case ChatProvider.Irc: + throw new NotImplementedException(); + case ChatProvider.Discord: + throw new NotImplementedException(); + default: + throw new InvalidOperationException(String.Format(CultureInfo.InvariantCulture, "Invalid ChatProvider: {0}", settings.Provider)); + } + } + } +} diff --git a/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs b/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs new file mode 100644 index 0000000000..7abe357269 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Providers/IProvider.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Tgstation.Server.Host.Components.Chat.Providers +{ + /// + /// For interacting with a chat service + /// + interface IProvider : IDisposable + { + /// + /// If the + /// + bool Connected { get; } + + /// + /// The that indicates the was mentioned + /// + string BotMention { get; } + + /// + /// Get a resulting in the next the recieves or on a disconnect + /// + Task NextMessage { get; } + + /// + /// Attempt to connect the + /// + /// The for the operation + /// A resulting in on success, otherwise + Task Connect(CancellationToken cancellationToken); + + /// + /// Gracefully disconnects the provider. Implies a call to + /// + /// The for the operation + /// A representing the running operation + Task Disconnect(CancellationToken cancellationToken); + + /// + /// Get the s for given + /// + /// The s to map + /// The for the operation + /// A resulting in a of the s representing + Task> MapChannels(IEnumerable channels, CancellationToken cancellationToken); + + /// + /// Send a message to the + /// + /// The to send to + /// The message contents + /// The for the operation + /// A representing the running operation + Task SendMessage(long 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 new file mode 100644 index 0000000000..855752e790 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/Response.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace Tgstation.Server.Host.Components.Chat +{ + /// + /// Represents a chat message requested by DD + /// + sealed class Response + { + /// + /// The message string + /// + public string Message { get; set; } + + /// + /// The list of internal channel ids to send to + /// + 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 new file mode 100644 index 0000000000..12eff58285 --- /dev/null +++ b/src/Tgstation.Server.Host/Components/Chat/User.cs @@ -0,0 +1,13 @@ +namespace Tgstation.Server.Host.Components.Chat +{ + /// + /// + /// + public sealed class User + { + long Id { get; set; } + string FriendlyName { get; set; } + string Mention { get; set; } + Channel Channel { get; set; } + } +} diff --git a/src/Tgstation.Server.Host/Components/DreamMaker.cs b/src/Tgstation.Server.Host/Components/DreamMaker.cs index 75ac5c271b..aaeb1204e3 100644 --- a/src/Tgstation.Server.Host/Components/DreamMaker.cs +++ b/src/Tgstation.Server.Host/Components/DreamMaker.cs @@ -1,4 +1,5 @@ -using System; +using Microsoft.Extensions.Logging; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -60,6 +61,14 @@ namespace Tgstation.Server.Host.Components /// The for /// readonly IApplication application; + /// + /// The for + /// + readonly IEventConsumer eventConsumer; + /// + /// The for + /// + readonly ILogger logger; /// /// Construct @@ -70,8 +79,9 @@ namespace Tgstation.Server.Host.Components /// The value of /// The value of /// The value of - /// - public DreamMaker(IByond byond, IIOManager ioManager, IConfiguration configuration, ISessionControllerFactory sessionControllerFactory, ICompileJobConsumer compileJobConsumer, IApplication application) + /// The value of + /// The value of + public DreamMaker(IByond byond, IIOManager ioManager, IConfiguration configuration, ISessionControllerFactory sessionControllerFactory, ICompileJobConsumer compileJobConsumer, IApplication application, IEventConsumer eventConsumer, ILogger logger) { this.byond = byond; this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); @@ -79,6 +89,8 @@ namespace Tgstation.Server.Host.Components this.sessionControllerFactory = sessionControllerFactory ?? throw new ArgumentNullException(nameof(sessionControllerFactory)); this.compileJobConsumer = compileJobConsumer ?? throw new ArgumentNullException(nameof(compileJobConsumer)); this.application = application ?? throw new ArgumentNullException(nameof(application)); + this.eventConsumer = eventConsumer ?? throw new ArgumentNullException(nameof(eventConsumer)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); } /// @@ -86,9 +98,10 @@ namespace Tgstation.Server.Host.Components /// /// The timeout in seconds for validation /// The for the operation + /// The current /// The for the operation /// A resulting in if the DMAPI was successfully validated, otherwise - async Task VerifyApi(int timeout, Models.CompileJob job, CancellationToken cancellationToken) + async Task VerifyApi(int timeout, Models.CompileJob job, IByondExecutableLock byondLock, CancellationToken cancellationToken) { var launchParameters = new DreamDaemonLaunchParameters { @@ -102,7 +115,7 @@ namespace Tgstation.Server.Host.Components var provider = new TemporaryDmbProvider(ioManager.ResolvePath(ioManager.GetDirectoryName(dirA)), ioManager.ResolvePath(ioManager.ConcatPath(dirA, String.Concat(job.DmeName, DmbExtension)))); var timeoutAt = DateTimeOffset.Now.AddSeconds(timeout); - using (var controller = await sessionControllerFactory.LaunchNew(launchParameters, provider, true, true, true, cancellationToken).ConfigureAwait(false)) + using (var controller = await sessionControllerFactory.LaunchNew(launchParameters, provider, byondLock, true, true, true, cancellationToken).ConfigureAwait(false)) { var timeoutTask = Task.Delay(timeoutAt - DateTimeOffset.Now, cancellationToken); @@ -119,10 +132,10 @@ namespace Tgstation.Server.Host.Components /// Compiles a .dme with DreamMaker /// /// The path to the DreamMaker executable - /// The for the operation + /// The for the operation /// The for the operation /// A representing the running operation - async Task RunDreamMaker(string dreamMakerPath, Host.Models.CompileJob job, CancellationToken cancellationToken) + async Task RunDreamMaker(string dreamMakerPath, Models.CompileJob job, CancellationToken cancellationToken) { using (var dm = new Process()) { @@ -169,7 +182,7 @@ namespace Tgstation.Server.Host.Components /// /// Adds server side includes to the .dme being compiled /// - /// The for the operation + /// The for the operation /// The for the operation /// A representing the running operation async Task ModifyDme(Models.CompileJob job, CancellationToken cancellationToken) @@ -211,6 +224,8 @@ namespace Tgstation.Server.Host.Components /// public async Task Compile(string projectName, int apiValidateTimeout, IRepository repository, CancellationToken cancellationToken) { + logger.LogTrace("Begin Compile"); + await eventConsumer.HandleEvent(EventType.CompileStart, new List{ repository.Origin }, cancellationToken).ConfigureAwait(false); try { Status = CompilerStatus.Copying; @@ -266,18 +281,18 @@ namespace Tgstation.Server.Host.Components Status = CompilerStatus.Verifying; - ddVerified = job.ExitCode == 0 && await VerifyApi(apiValidateTimeout, job, cancellationToken).ConfigureAwait(false); + ddVerified = job.ExitCode == 0 && await VerifyApi(apiValidateTimeout, job, byondLock, cancellationToken).ConfigureAwait(false); } if (!ddVerified) //server never validated or compile failed - await CleanupFailedCompile().ConfigureAwait(false); + await Task.WhenAll(CleanupFailedCompile(), eventConsumer.HandleEvent(EventType.CompileFailure, new List { job.ExitCode == 0 ? "1" : "0" }, cancellationToken)).ConfigureAwait(false); else { job.DMApiValidated = true; Status = CompilerStatus.Duplicating; - + //duplicate the dmb et al await ioManager.CopyDirectory(dirA, dirB, null, cancellationToken).ConfigureAwait(false); @@ -285,8 +300,10 @@ namespace Tgstation.Server.Host.Components //symlink in the static data var symATask = configuration.SymlinkStaticFilesTo(fullDirA, cancellationToken); - await configuration.SymlinkStaticFilesTo(ioManager.ResolvePath(dirB), cancellationToken).ConfigureAwait(false); - await symATask.ConfigureAwait(false); + var symBTask = configuration.SymlinkStaticFilesTo(ioManager.ResolvePath(dirB), cancellationToken); + + await Task.WhenAll(symATask, symBTask).ConfigureAwait(false); + await eventConsumer.HandleEvent(EventType.CompileComplete, null, cancellationToken).ConfigureAwait(false); } await compileJobConsumer.LoadCompileJob(job, cancellationToken).ConfigureAwait(false); return job; @@ -297,6 +314,11 @@ namespace Tgstation.Server.Host.Components throw; } } + catch (OperationCanceledException) + { + await eventConsumer.HandleEvent(EventType.CompileCancelled, null, default).ConfigureAwait(false); + throw; + } finally { Status = CompilerStatus.Idle; diff --git a/src/Tgstation.Server.Host/Components/EventType.cs b/src/Tgstation.Server.Host/Components/EventType.cs index 887d8ffe19..53e6c9c9bd 100644 --- a/src/Tgstation.Server.Host/Components/EventType.cs +++ b/src/Tgstation.Server.Host/Components/EventType.cs @@ -8,97 +8,65 @@ /// /// Parameters: Reference name, commit sha /// - RepoResetOrigin, + RepoResetOrigin = 0, /// /// Parameters: Reference name, commit sha /// - RepoCheckout, + RepoCheckout = 1, /// /// No parameters /// - RepoFetch, + RepoFetch = 2, /// /// Parameters: Comma separated list in form of "#{Pull Request Number} @ {7 character SHA} /// - RepoMergePullRequests, + RepoMergePullRequests = 3, /// /// Parameters: Current version, new version /// - ByondChangeStart, + ByondChangeStart = 4, /// /// No parameters /// - ByondChangeCancelled, + ByondChangeCancelled = 5, /// /// Parameters: Error string /// - ByondFail, + ByondFail = 6, /// /// No parameters /// - ByondStageComplete, + ByondStageComplete = 7, /// /// No parameters /// - ByondChangeComplete, + ByondChangeComplete = 8, /// - /// Parameters: Commit sha, parameter of + /// Parameters: Origin commit sha /// - CompileStart, + CompileStart = 9, /// /// No parameters /// - CompileCancelled, + CompileCancelled = 10, /// - /// Parameters: Error string + /// Parameters: "1" if compile succeeded and api validation failed, "0" otherwise /// - CompileFailure, + CompileFailure = 11, /// /// No parameters /// - CompileComplete, - - /// - /// Parameters: Access token - /// - DDLaunched, + CompileComplete = 12, + /// /// Parameters: Exit code /// - DDCrash, + DDOtherCrash = 13, /// /// No parameters /// - DDExit, - /// - /// Parameters: Exit code - /// - DDOtherCrash, - /// - /// No parameters - /// - DDOtherExit, - /// - /// No parameters - /// - DDRestart, - /// - /// No parameters - /// - DDBeginGracefulRestart, - /// - /// No parameters - /// - DDBeginGracefulShutdown, - /// - /// No parameters - /// - DDCancelGraceful, - /// - /// No parameters - /// - DDTerminated, + DDOtherExit = 14, } } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/ISessionController.cs b/src/Tgstation.Server.Host/Components/Watchdog/ISessionController.cs index 949e1b53d9..faa997cbf1 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/ISessionController.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/ISessionController.cs @@ -52,7 +52,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// Sends a command to DreamDaemon through /world/Topic() /// - /// The command to send + /// The sanitized command to send /// The for the operation /// A resulting in the result of /world/Topic() Task SendCommand(string command, CancellationToken cancellationToken); diff --git a/src/Tgstation.Server.Host/Components/Watchdog/ISessionControllerFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/ISessionControllerFactory.cs index 32faa37b0e..9f307f44c0 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/ISessionControllerFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/ISessionControllerFactory.cs @@ -14,12 +14,13 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// The to use /// The to use + /// The current if any /// If the of should be used /// If the of should be used /// If the should only validate the DMAPI then exit /// The for the operation /// A resulting in a new - Task LaunchNew(DreamDaemonLaunchParameters launchParameters, IDmbProvider dmbProvider, bool primaryPort, bool primaryDirectory, bool apiValidate, CancellationToken cancellationToken); + Task LaunchNew(DreamDaemonLaunchParameters launchParameters, IDmbProvider dmbProvider, IByondExecutableLock currentByondLock, bool primaryPort, bool primaryDirectory, bool apiValidate, CancellationToken cancellationToken); /// /// Create a from an existing DreamDaemon instance diff --git a/src/Tgstation.Server.Host/Components/Watchdog/SessionController.cs b/src/Tgstation.Server.Host/Components/Watchdog/SessionController.cs index 43913c7e54..4937f7e170 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/SessionController.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/SessionController.cs @@ -1,5 +1,6 @@ using Byond.TopicSender; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using System; using System.Collections.Generic; @@ -101,15 +102,20 @@ namespace Tgstation.Server.Host.Components.Watchdog readonly ISession session; /// - /// The for the + /// The for the /// - readonly IChatJsonTrackingContext chatJsonTrackingContext; + readonly IJsonTrackingContext chatJsonTrackingContext; /// /// The for the /// readonly IChat chat; + /// + /// The for the + /// + readonly ILogger logger; + /// /// The waits on when DreamDaemon currently has it's ports closed /// @@ -147,15 +153,17 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The used to construct /// The value of /// The value of - public SessionController(ReattachInformation reattachInformation, ISession session, IByondTopicSender byondTopicSender, IInteropRegistrar interopRegistrar, IChatJsonTrackingContext chatJsonTrackingContext, IChat chat) + /// The value of + public SessionController(ReattachInformation reattachInformation, ISession session, IByondTopicSender byondTopicSender, IInteropRegistrar interopRegistrar, IJsonTrackingContext chatJsonTrackingContext, IChat chat, ILogger logger) { this.chatJsonTrackingContext = chatJsonTrackingContext; //null valid this.reattachInformation = reattachInformation ?? throw new ArgumentNullException(nameof(reattachInformation)); this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender)); - this.session = session ?? throw new ArgumentNullException(nameof(session)); - this.chat = chat ?? throw new ArgumentNullException(nameof(chat)); if (interopRegistrar == null) throw new ArgumentNullException(nameof(interopRegistrar)); + this.session = session ?? throw new ArgumentNullException(nameof(session)); + this.chat = chat ?? throw new ArgumentNullException(nameof(chat)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); interopContext = interopRegistrar.Register(reattachInformation.AccessIdentifier, this); @@ -254,9 +262,29 @@ namespace Tgstation.Server.Host.Components.Watchdog } /// - public Task SendCommand(string command, CancellationToken cancellationToken) => byondTopicSender.SendTopic(new IPEndPoint(IPAddress.Loopback, reattachInformation.Port), String.Format(CultureInfo.InvariantCulture, "?{0}={1}&{2}={3}", InteropConstants.DMInteropAccessIdentifier, reattachInformation.AccessIdentifier, InteropConstants.DMParameterCommand, command), cancellationToken); + public async Task SendCommand(string command, CancellationToken cancellationToken) + { + try + { + return await byondTopicSender.SendTopic( + new IPEndPoint(IPAddress.Loopback, reattachInformation.Port), + String.Format(CultureInfo.InvariantCulture, + "?{0}={1}&{2}={3}", + byondTopicSender.SanitizeString(InteropConstants.DMInteropAccessIdentifier), + byondTopicSender.SanitizeString(reattachInformation.AccessIdentifier), + byondTopicSender.SanitizeString(InteropConstants.DMParameterCommand), + //intentionally don't sanitize command, that's up to the caller + command), + cancellationToken).ConfigureAwait(false); + } + catch (Exception e) + { + logger.LogInformation("Send command exception:{0}{1}", Environment.NewLine, e.Message); + return null; + } + } - async Task SetPortImpl(ushort port, CancellationToken cancellationToken) => await SendCommand(String.Format(CultureInfo.InvariantCulture, "{0}&{1}={2}", InteropConstants.DMTopicChangePort, InteropConstants.DMParameterNewPort, port), cancellationToken).ConfigureAwait(false) == InteropConstants.DMResponseSuccess; + async Task SetPortImpl(ushort port, CancellationToken cancellationToken) => await SendCommand(String.Format(CultureInfo.InvariantCulture, "{0}&{1}={2}", byondTopicSender.SanitizeString(InteropConstants.DMTopicChangePort), byondTopicSender.SanitizeString(InteropConstants.DMParameterNewPort), byondTopicSender.SanitizeString(port.ToString(CultureInfo.InvariantCulture))), cancellationToken).ConfigureAwait(false) == InteropConstants.DMResponseSuccess; /// public async Task ClosePort(CancellationToken cancellationToken) diff --git a/src/Tgstation.Server.Host/Components/Watchdog/SessionControllerFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/SessionControllerFactory.cs index 19de65758f..4f2dc78885 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/SessionControllerFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/SessionControllerFactory.cs @@ -1,4 +1,5 @@ using Byond.TopicSender; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; using System; using System.Globalization; @@ -61,6 +62,11 @@ namespace Tgstation.Server.Host.Components.Watchdog /// readonly IChat chat; + /// + /// The for the + /// + readonly ILoggerFactory loggerFactory; + /// /// Construct a /// @@ -73,7 +79,8 @@ 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) + /// 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) { this.executor = executor ?? throw new ArgumentNullException(nameof(executor)); this.byond = byond ?? throw new ArgumentNullException(nameof(byond)); @@ -84,10 +91,11 @@ namespace Tgstation.Server.Host.Components.Watchdog this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager)); this.chat = chat ?? throw new ArgumentNullException(nameof(chat)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); } /// - public async Task LaunchNew(DreamDaemonLaunchParameters launchParameters, IDmbProvider dmbProvider, bool primaryPort, bool primaryDirectory, bool apiValidate, CancellationToken cancellationToken) + public async Task LaunchNew(DreamDaemonLaunchParameters launchParameters, IDmbProvider dmbProvider, IByondExecutableLock currentByondLock, bool primaryPort, bool primaryDirectory, bool apiValidate, CancellationToken cancellationToken) { var portToUse = primaryPort ? launchParameters.PrimaryPort : launchParameters.SecondaryPort; if (!portToUse.HasValue) @@ -133,11 +141,11 @@ namespace Tgstation.Server.Host.Components.Watchdog var chatJsonTrackingContext = await chatJsonTrackingTask.ConfigureAwait(false); try { - var byondLock = byond.UseExecutables(dmbProvider.CompileJob.ByondVersion); + var byondLock = currentByondLock ?? byond.UseExecutables(dmbProvider.CompileJob.ByondVersion); try { - var parameters = String.Format(CultureInfo.InvariantCulture, "{2}={0}&{3}={1}", application.Version, interopJsonFile, InteropConstants.DMParamHostVersion, InteropConstants.DMParamInfoJson); - + //more sanitization here cause it uses the same scheme + var parameters = String.Format(CultureInfo.InvariantCulture, "{2}={0}&{3}={1}", byondTopicSender.SanitizeString(application.Version.ToString()), byondTopicSender.SanitizeString(interopJsonFile), byondTopicSender.SanitizeString(InteropConstants.DMParamHostVersion), byondTopicSender.SanitizeString(InteropConstants.DMParamInfoJson)); var session = executor.RunDreamDaemon(launchParameters, byondLock, dmbProvider, parameters, !primaryPort, !primaryDirectory); try @@ -149,7 +157,7 @@ namespace Tgstation.Server.Host.Components.Watchdog IsPrimary = primaryDirectory, Port = portToUse.Value, ProcessId = session.ProcessId - }, session, byondTopicSender, interopRegistrar, chatJsonTrackingContext, chat); + }, session, byondTopicSender, interopRegistrar, chatJsonTrackingContext, chat, loggerFactory.CreateLogger()); } catch { @@ -159,7 +167,8 @@ namespace Tgstation.Server.Host.Components.Watchdog } catch { - byondLock.Dispose(); + if (currentByondLock == null) + byondLock.Dispose(); throw; } } @@ -186,7 +195,7 @@ namespace Tgstation.Server.Host.Components.Watchdog var session = executor.AttachToDreamDaemon(reattachInformation.ProcessId, byondLock); try { - return new SessionController(reattachInformation, session, byondTopicSender, interopRegistrar, chatJsonTrackingContext, chat); + return new SessionController(reattachInformation, session, byondTopicSender, interopRegistrar, chatJsonTrackingContext, chat, loggerFactory.CreateLogger()); } catch { diff --git a/src/Tgstation.Server.Host/Components/Watchdog/Watchdog.cs b/src/Tgstation.Server.Host/Components/Watchdog/Watchdog.cs index d2a32643e2..c64d6212c7 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/Watchdog.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/Watchdog.cs @@ -1,8 +1,10 @@ -using Microsoft.Extensions.Logging; +using Byond.TopicSender; +using Microsoft.Extensions.Logging; using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -13,7 +15,7 @@ using Tgstation.Server.Host.Core; namespace Tgstation.Server.Host.Components.Watchdog { /// - sealed class Watchdog : IWatchdog, IEventConsumer + sealed class Watchdog : IWatchdog, IEventConsumer, ICustomCommandHandler { /// /// The time in milliseconds to wait from starting to start . Does not take responsiveness into account @@ -36,7 +38,7 @@ namespace Tgstation.Server.Host.Components.Watchdog public DreamDaemonLaunchParameters LastLaunchParameters { get; private set; } /// - public RebootState? RebootState => Running ? (RebootState?)(AlphaIsActive ? alphaServer?.RebootState : bravoServer?.RebootState) : null; + public RebootState? RebootState => Running ? (AlphaIsActive ? alphaServer?.RebootState : bravoServer?.RebootState) : null; /// /// The for the @@ -68,6 +70,11 @@ namespace Tgstation.Server.Host.Components.Watchdog /// readonly IDatabaseContextFactory databaseContextFactory; + /// + /// The for the + /// + readonly IByondTopicSender byondTopicSender; + /// /// The for the /// @@ -104,10 +111,11 @@ namespace Tgstation.Server.Host.Components.Watchdog /// The value of /// The value of /// The value of + /// The value of /// The initial value of /// The containing the value of /// The value of - public Watchdog(IChat chat, ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, IServerUpdater serverUpdater, ILogger logger, IReattachInfoHandler reattachInfoHandler, IDatabaseContextFactory databaseContextFactory, DreamDaemonLaunchParameters initialLaunchParameters, Models.Instance instance, bool autoStart) + public Watchdog(IChat chat, ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, IServerUpdater serverUpdater, ILogger logger, IReattachInfoHandler reattachInfoHandler, IDatabaseContextFactory databaseContextFactory, IByondTopicSender byondTopicSender, DreamDaemonLaunchParameters initialLaunchParameters, Models.Instance instance, bool autoStart) { this.chat = chat ?? throw new ArgumentNullException(nameof(chat)); this.sessionControllerFactory = sessionControllerFactory ?? throw new ArgumentNullException(nameof(sessionControllerFactory)); @@ -115,6 +123,7 @@ namespace Tgstation.Server.Host.Components.Watchdog this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.reattachInfoHandler = reattachInfoHandler ?? throw new ArgumentNullException(nameof(reattachInfoHandler)); this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender)); instanceId = instance?.Id ?? throw new ArgumentNullException(nameof(instance)); this.autoStart = autoStart; @@ -123,6 +132,8 @@ namespace Tgstation.Server.Host.Components.Watchdog serverUpdater.RegisterForUpdate(() => releaseServers = true); + chat.RegisterCommandHandler(this); + AlphaIsActive = true; ActiveLaunchParameters = initialLaunchParameters; releaseServers = false; @@ -354,7 +365,6 @@ namespace Tgstation.Server.Host.Components.Watchdog return null; } - Task chatTask; //this is necessary, the monitor could be in it's sleep loop trying to restart if (startMonitor && await StopMonitor().ConfigureAwait(false)) @@ -371,46 +381,26 @@ namespace Tgstation.Server.Host.Components.Watchdog if (alphaServer != null || bravoServer != null) throw new InvalidOperationException("Entered LaunchNoLock with one or more of the servers not being null!"); - WatchdogReattachInformation reattachInfo = doReattach ? await reattachInfoHandler.Load(cancellationToken).ConfigureAwait(false) : null; + var reattachInfo = doReattach ? await reattachInfoHandler.Load(cancellationToken).ConfigureAwait(false) : null; var doesntNeedNewDmb = doReattach && reattachInfo.Alpha != null && reattachInfo.Bravo != null; var dmbToUse = doesntNeedNewDmb ? null : await dmbFactory.LockNextDmb(cancellationToken).ConfigureAwait(false); Task alphaServerTask = null; try { - try - { - if (!doReattach || reattachInfo.Alpha == null) - alphaServerTask = sessionControllerFactory.LaunchNew(ActiveLaunchParameters, dmbToUse, true, true, false, alphaStartCts.Token); - else - alphaServerTask = sessionControllerFactory.Reattach(reattachInfo.Alpha, cancellationToken); - //do a few seconds of delay so that any backends the servers use know that alpha came first - await Task.Delay(AlphaBravoStartupSeperationInterval, cancellationToken).ConfigureAwait(false); - Task bravoServerTask; - if (!doReattach || reattachInfo.Bravo == null) - bravoServerTask = sessionControllerFactory.LaunchNew(ActiveLaunchParameters, dmbToUse, false, false, false, cancellationToken); - else - bravoServerTask = sessionControllerFactory.Reattach(reattachInfo.Bravo, cancellationToken); + if (!doReattach || reattachInfo.Alpha == null) + alphaServerTask = sessionControllerFactory.LaunchNew(ActiveLaunchParameters, dmbToUse, null, true, true, false, alphaStartCts.Token); + else + alphaServerTask = sessionControllerFactory.Reattach(reattachInfo.Alpha, cancellationToken); + //do a few seconds of delay so that any backends the servers use know that alpha came first + await Task.Delay(AlphaBravoStartupSeperationInterval, cancellationToken).ConfigureAwait(false); + Task bravoServerTask; + if (!doReattach || reattachInfo.Bravo == null) + bravoServerTask = sessionControllerFactory.LaunchNew(ActiveLaunchParameters, dmbToUse, null, false, false, false, cancellationToken); + else + bravoServerTask = sessionControllerFactory.Reattach(reattachInfo.Bravo, cancellationToken); - bravoServer = await bravoServerTask.ConfigureAwait(false); - alphaServer = await alphaServerTask.ConfigureAwait(false); - } - catch - { - if (alphaServerTask != null) - if (alphaServerTask.Status == TaskStatus.RanToCompletion) - alphaServer = await alphaServerTask.ConfigureAwait(false); - else - { - alphaStartCts.Cancel(); - try - { - alphaServer = await alphaServerTask.ConfigureAwait(false); - } - catch { } - } - throw; - } + await Task.WhenAll(alphaServerTask, bravoServerTask).ConfigureAwait(false); async Task CheckLaunch(ISessionController controller, string serverName) { @@ -466,7 +456,7 @@ namespace Tgstation.Server.Host.Components.Watchdog catch { if (alphaServer == null && bravoServer == null) - dmbToUse.Dispose(); //guaranteed to not be null here + dmbToUse.Dispose(); //guaranteed to not be null here DisposeAndNullControllers(); throw; } @@ -531,6 +521,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// public async Task HandleEvent(EventType eventType, IEnumerable parameters, CancellationToken cancellationToken) { + string results; using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false)) { if (!Running) @@ -540,11 +531,42 @@ namespace Tgstation.Server.Host.Components.Watchdog foreach (var I in parameters) { builder.Append("&"); - builder.Append(I); + builder.Append(byondTopicSender.SanitizeString(I)); } var activeServer = AlphaIsActive ? alphaServer : bravoServer; - await activeServer.SendCommand(builder.ToString(), cancellationToken).ConfigureAwait(false); + results = await activeServer.SendCommand(builder.ToString(), cancellationToken).ConfigureAwait(false); + } + + if (results == null) + return; + + List responses; + try + { + responses = JsonConvert.DeserializeObject>(results); + } + catch + { + logger.LogInformation("Recieved invalid response from DD when parsing event {0}:{1}{2}", eventType, Environment.NewLine, results); + return; + } + + await Task.WhenAll(responses.Select(x => chat.SendMessage(x.Message, x.ChannelIds, cancellationToken))).ConfigureAwait(false); + } + + /// + public async Task HandleChatCommand(string commandName, string arguments, Chat.User sender, CancellationToken cancellationToken) + { + using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken).ConfigureAwait(false)) + { + if (!Running) + return "ERROR: Server offline!"; + + var command = String.Format(CultureInfo.InvariantCulture, "{0}&{1}={2}", byondTopicSender.SanitizeString(InteropConstants.DMTopicChatCommand), byondTopicSender.SanitizeString(InteropConstants.DMParameterData), byondTopicSender.SanitizeString(JsonConvert.SerializeObject(arguments))); + + var activeServer = AlphaIsActive ? alphaServer : bravoServer; + return await activeServer.SendCommand(command, cancellationToken).ConfigureAwait(false) ?? "ERROR: Bad topic exchange!"; } } } diff --git a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs index ad068c4b31..6c948960d0 100644 --- a/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs +++ b/src/Tgstation.Server.Host/Components/Watchdog/WatchdogFactory.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Logging; +using Byond.TopicSender; +using Microsoft.Extensions.Logging; using System; using Tgstation.Server.Api.Models.Internal; using Tgstation.Server.Host.Components.Chat; @@ -17,7 +18,7 @@ namespace Tgstation.Server.Host.Components.Watchdog /// /// The for the /// - readonly ISessionControllerFactory sessionManagerFactory; + readonly ISessionControllerFactory sessionControllerFactory; /// /// The for the @@ -39,6 +40,11 @@ namespace Tgstation.Server.Host.Components.Watchdog /// readonly IDatabaseContextFactory databaseContextFactory; + /// + /// The for the + /// + readonly IByondTopicSender byondTopicSender; + /// /// The for the /// @@ -49,24 +55,26 @@ namespace Tgstation.Server.Host.Components.Watchdog /// Construct a /// /// The value of - /// The value of + /// The value of /// The value of /// The value of /// The value of /// The value of + /// The value of /// The value of - public WatchdogFactory(IChat chat, ISessionControllerFactory sessionManagerFactory, IServerUpdater serverUpdater, ILoggerFactory loggerFactory, IReattachInfoHandler reattachInfoHandler, IDatabaseContextFactory databaseContextFactory, Models.Instance instance) + public WatchdogFactory(IChat chat, ISessionControllerFactory sessionControllerFactory, IServerUpdater serverUpdater, ILoggerFactory loggerFactory, IReattachInfoHandler reattachInfoHandler, IDatabaseContextFactory databaseContextFactory, IByondTopicSender byondTopicSender, Models.Instance instance) { this.chat = chat ?? throw new ArgumentNullException(nameof(chat)); - this.sessionManagerFactory = sessionManagerFactory ?? throw new ArgumentNullException(nameof(sessionManagerFactory)); + this.sessionControllerFactory = sessionControllerFactory ?? throw new ArgumentNullException(nameof(sessionControllerFactory)); this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater)); this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); this.reattachInfoHandler = reattachInfoHandler ?? throw new ArgumentNullException(nameof(reattachInfoHandler)); this.databaseContextFactory = databaseContextFactory ?? throw new ArgumentNullException(nameof(databaseContextFactory)); + this.byondTopicSender = byondTopicSender ?? throw new ArgumentNullException(nameof(byondTopicSender)); this.instance = instance ?? throw new ArgumentNullException(nameof(instance)); } /// - public IWatchdog CreateWatchdog(IDmbFactory dmbFactory, DreamDaemonSettings settings) => new Watchdog(chat, sessionManagerFactory, dmbFactory, serverUpdater, loggerFactory.CreateLogger(), reattachInfoHandler, databaseContextFactory, settings, instance, settings.AutoStart.Value); + public IWatchdog CreateWatchdog(IDmbFactory dmbFactory, DreamDaemonSettings settings) => new Watchdog(chat, sessionControllerFactory, dmbFactory, serverUpdater, loggerFactory.CreateLogger(), reattachInfoHandler, databaseContextFactory, byondTopicSender, settings, instance, settings.AutoStart.Value); } } diff --git a/src/Tgstation.Server.Host/Controllers/ChatController.cs b/src/Tgstation.Server.Host/Controllers/ChatController.cs new file mode 100644 index 0000000000..2a19a797e3 --- /dev/null +++ b/src/Tgstation.Server.Host/Controllers/ChatController.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Net; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Tgstation.Server.Api.Models; +using Tgstation.Server.Api.Rights; +using Tgstation.Server.Host.Components; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; +using Z.EntityFramework.Plus; + +namespace Tgstation.Server.Host.Controllers +{ + /// + /// for managing + /// + [TgsAuthorize] + public sealed class ChatController : ModelController + { + /// + /// The for the + /// + readonly IInstanceManager instanceManager; + + /// + /// Construct a + /// + /// The for the + /// The for the + /// The value of + public ChatController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IInstanceManager instanceManager) : base(databaseContext, authenticationContextFactory) + { + this.instanceManager = instanceManager ?? throw new ArgumentNullException(nameof(instanceManager)); + } + + static Models.ChatChannel ConvertApiChatChannel(Api.Models.ChatChannel api) => new Models.ChatChannel + { + DiscordChannelId = api.DiscordChannelId, + IrcChannel = api.IrcChannel, + IsAdminChannel = api.IsAdminChannel, + IsWatchdogChannel = api.IsWatchdogChannel + }; + + /// + [TgsAuthorize(ChatSettingsRights.Create)] + public override async Task Create([FromBody] Api.Models.ChatSettings model, CancellationToken cancellationToken) + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + + if (String.IsNullOrWhiteSpace(model.Name)) + return BadRequest(new { message = "name cannot be null or whitespace!" }); + + if (String.IsNullOrWhiteSpace(model.ConnectionString)) + return BadRequest(new { message = "connection_string cannot be null or whitespace!" }); + + if (!model.Provider.HasValue) + return BadRequest(new { message = "provider cannot be null!" }); + + if (!model.Enabled.HasValue) + return BadRequest(new { message = "enabled cannot be null!" }); + + //try to update das db first + var dbModel = new Models.ChatSettings + { + Name = model.Name, + ConnectionString = model.ConnectionString, + Enabled = model.Enabled, + Channels = model.Channels?.Select(x => ConvertApiChatChannel(x)).ToList() ?? new List(), + InstanceId = Instance.Id, + Provider = model.Provider, + }; + DatabaseContext.ChatSettings.Add(dbModel); + DatabaseContext.ChatChannels.AddRange(dbModel.Channels); + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + + try + { + try + { + //try to create it + var instance = instanceManager.GetInstance(Instance); + await instance.Chat.ChangeSettings(dbModel, cancellationToken).ConfigureAwait(false); + + if (dbModel.Channels.Count > 0) + await instance.Chat.ChangeChannels(dbModel.Id, dbModel.Channels, cancellationToken).ConfigureAwait(false); + } + catch + { + //undo the add + DatabaseContext.ChatSettings.Remove(dbModel); + await DatabaseContext.Save(default).ConfigureAwait(false); + throw; + } + } + catch (InvalidOperationException e) + { + return BadRequest(new { message = e.Message }); + } + return Json(dbModel); + } + + /// + [TgsAuthorize(ChatSettingsRights.Delete)] + public override async Task Delete([FromBody] Api.Models.ChatSettings model, CancellationToken cancellationToken) + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + + var instance = instanceManager.GetInstance(Instance); + await Task.WhenAll(instance.Chat.DeleteConnection(model.Id, cancellationToken), DatabaseContext.ChatSettings.Where(x => x.Id == model.Id).DeleteAsync(cancellationToken)).ConfigureAwait(false); + + return Ok(); + } + + /// + [TgsAuthorize(ChatSettingsRights.Read)] + public override async Task List(CancellationToken cancellationToken) + { + var query = DatabaseContext.ChatSettings.Where(x => x.InstanceId == Instance.Id).Include(x => x.Channels); + + var results = await query.ToListAsync(cancellationToken).ConfigureAwait(false); + + var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatSettings) & (int)ChatSettingsRights.ReadConnectionString) != 0; + + if (!connectionStrings) + foreach (var I in results) + I.ConnectionString = null; + + return Json(results); + } + + /// + [TgsAuthorize(ChatSettingsRights.WriteChannels | ChatSettingsRights.WriteConnectionString | ChatSettingsRights.WriteEnabled | ChatSettingsRights.WriteName | ChatSettingsRights.WriteProvider)] + public override async Task Update([FromBody] Api.Models.ChatSettings model, CancellationToken cancellationToken) + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + + var query = DatabaseContext.ChatSettings.Where(x => x.InstanceId == Instance.Id && x.Id == model.Id).Include(x => x.Channels); + + var current = await query.FirstOrDefaultAsync(cancellationToken).ConfigureAwait(false); + + if (current == default) + return StatusCode((int)HttpStatusCode.Gone); + + var userRights = (ChatSettingsRights)AuthenticationContext.GetRight(RightsType.ChatSettings); + + bool anySettingsModified = false; + + bool CheckModified(Expression> expression, ChatSettingsRights requiredRight) + { + var memberSelectorExpression = (MemberExpression)expression.Body; + var property = (PropertyInfo)memberSelectorExpression.Member; + + var newVal = property.GetValue(model); + if (newVal == null) + return false; + if (!userRights.HasFlag(requiredRight) && property.GetValue(current) != newVal) + return true; + + property.SetValue(current, newVal); + anySettingsModified = true; + return false; + }; + + if (!CheckModified(x => x.ConnectionString, ChatSettingsRights.WriteConnectionString) + || !CheckModified(x => x.Enabled, ChatSettingsRights.WriteEnabled) + || !CheckModified(x => x.Name, ChatSettingsRights.WriteName) + || !CheckModified(x => x.Provider, ChatSettingsRights.WriteProvider) + || (model.Channels != null && !userRights.HasFlag(ChatSettingsRights.WriteChannels))) + return Forbid(); + + if (model.Channels != null) + { + DatabaseContext.ChatChannels.RemoveRange(current.Channels); + var dbChannels = model.Channels.Select(x => ConvertApiChatChannel(x)).ToList(); + DatabaseContext.ChatChannels.AddRange(dbChannels); + current.Channels = dbChannels; + } + + await DatabaseContext.Save(cancellationToken).ConfigureAwait(false); + + var chat = instanceManager.GetInstance(Instance).Chat; + + if (anySettingsModified) + //have to rebuild the thing first + await chat.ChangeSettings(current, cancellationToken).ConfigureAwait(false); + + if (model.Channels != null) + await chat.ChangeChannels(current.Id, current.Channels, cancellationToken).ConfigureAwait(false); + + if(userRights.HasFlag(ChatSettingsRights.Read)) + return Json(current); + return Ok(); + } + } +} diff --git a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs index 375e4dace8..c1a4a6b673 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamDaemonController.cs @@ -18,10 +18,10 @@ using Tgstation.Server.Host.Security; namespace Tgstation.Server.Host.Controllers { /// - /// for managing + /// for managing the /// [Route("/" + nameof(DreamDaemon))] - public sealed class DreamDaemonController : ModelController + public sealed class DreamDaemonController : ModelController { /// /// The for the @@ -52,7 +52,7 @@ namespace Tgstation.Server.Host.Controllers var instance = instanceManager.GetInstance(Instance); if (instance.Watchdog.Running) - return StatusCode(HttpStatusCode.Gone); + return StatusCode((int)HttpStatusCode.Gone); await jobManager.RegisterOperation(new Models.Job { @@ -118,7 +118,7 @@ namespace Tgstation.Server.Host.Controllers var instance = instanceManager.GetInstance(Instance); if (!instance.Watchdog.Running) - return StatusCode(HttpStatusCode.Gone); + return StatusCode((int)HttpStatusCode.Gone); await instance.Watchdog.Terminate(false, cancellationToken).ConfigureAwait(false); return Ok(); diff --git a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs index 3e5844e7ef..b936a07f87 100644 --- a/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs +++ b/src/Tgstation.Server.Host/Controllers/DreamMakerController.cs @@ -80,7 +80,7 @@ namespace Tgstation.Server.Host.Controllers //alias for cancelling the latest job var job = await DatabaseContext.CompileJobs.OrderByDescending(x => x.Job.StartedAt).Select(x => new Job { Id = x.Job.Id, StoppedAt = x.Job.StoppedAt }).FirstAsync(cancellationToken).ConfigureAwait(false); if (job.StoppedAt != null) - return StatusCode(HttpStatusCode.Gone); + return StatusCode((int)HttpStatusCode.Gone); await jobManager.CancelJob(job, AuthenticationContext.User, cancellationToken).ConfigureAwait(false); return Ok(); } diff --git a/src/Tgstation.Server.Host/Controllers/InstanceController.cs b/src/Tgstation.Server.Host/Controllers/InstanceController.cs index 3bb4ab83c5..d5665f6a4b 100644 --- a/src/Tgstation.Server.Host/Controllers/InstanceController.cs +++ b/src/Tgstation.Server.Host/Controllers/InstanceController.cs @@ -108,7 +108,7 @@ namespace Tgstation.Server.Host.Controllers { var originalModel = await DatabaseContext.Instances.Where(x => x.Id == model.Id).FirstAsync(cancellationToken).ConfigureAwait(false); if (originalModel == default(Models.Instance)) - return StatusCode(HttpStatusCode.Gone); + return StatusCode((int)HttpStatusCode.Gone); throw new NotImplementedException(); } diff --git a/src/Tgstation.Server.Host/Controllers/JobController.cs b/src/Tgstation.Server.Host/Controllers/JobController.cs index 1dcab2f526..a84d50c8f5 100644 --- a/src/Tgstation.Server.Host/Controllers/JobController.cs +++ b/src/Tgstation.Server.Host/Controllers/JobController.cs @@ -64,7 +64,7 @@ namespace Tgstation.Server.Host.Controllers return Forbid(); if(job.StoppedAt != null) - return StatusCode(HttpStatusCode.Gone); + return StatusCode((int)HttpStatusCode.Gone); await jobManager.CancelJob(job, AuthenticationContext.User, cancellationToken).ConfigureAwait(false); return Ok(); diff --git a/src/Tgstation.Server.Host/Models/ChatChannel.cs b/src/Tgstation.Server.Host/Models/ChatChannel.cs index 8f57e1e885..3bd53864fd 100644 --- a/src/Tgstation.Server.Host/Models/ChatChannel.cs +++ b/src/Tgstation.Server.Host/Models/ChatChannel.cs @@ -9,7 +9,7 @@ public long Id { get; set; } /// - /// The + /// The /// public long ChatSettingsId { get; set; } diff --git a/src/Tgstation.Server.Host/Models/ChatSettings.cs b/src/Tgstation.Server.Host/Models/ChatSettings.cs index 5d3df56af5..1152181268 100644 --- a/src/Tgstation.Server.Host/Models/ChatSettings.cs +++ b/src/Tgstation.Server.Host/Models/ChatSettings.cs @@ -5,12 +5,7 @@ namespace Tgstation.Server.Host.Models { /// public sealed class ChatSettings : Api.Models.Internal.ChatSettings - { - /// - /// The row Id - /// - public long Id { get; set; } - + { /// /// The /// diff --git a/src/Tgstation.Server.Host/Models/DatabaseContext.cs b/src/Tgstation.Server.Host/Models/DatabaseContext.cs index 554118c0f4..ff966eba6f 100644 --- a/src/Tgstation.Server.Host/Models/DatabaseContext.cs +++ b/src/Tgstation.Server.Host/Models/DatabaseContext.cs @@ -52,9 +52,7 @@ namespace Tgstation.Server.Host.Models /// public DbSet InstanceUsers { get; set; } - /// - /// The s in the - /// + /// public DbSet ChatChannels { get; set; } /// diff --git a/src/Tgstation.Server.Host/Models/IDatabaseContext.cs b/src/Tgstation.Server.Host/Models/IDatabaseContext.cs index e08826dcc9..9e8d906905 100644 --- a/src/Tgstation.Server.Host/Models/IDatabaseContext.cs +++ b/src/Tgstation.Server.Host/Models/IDatabaseContext.cs @@ -49,6 +49,11 @@ namespace Tgstation.Server.Host.Models /// DbSet ChatSettings { get; set; } + /// + /// The in the + /// + DbSet ChatChannels { get; set; } + /// /// The in the /// diff --git a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj index 8dfd920fb5..aa1f136bcd 100644 --- a/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj +++ b/src/Tgstation.Server.Host/Tgstation.Server.Host.csproj @@ -34,19 +34,21 @@ + + - - - - - - - - - - - - + + + + + + + + + + + +