Merge pull request #539 from Cyberboss/ContinuedV4Work

Continued v4 work
This commit is contained in:
Jordan Brown
2018-07-16 22:40:56 -04:00
committed by GitHub
46 changed files with 1165 additions and 256 deletions
BIN
View File
Binary file not shown.
+3 -4
View File
@@ -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
+10 -8
View File
@@ -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
+1 -8
View File
@@ -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)
@@ -13,11 +13,16 @@
/// <summary>
/// The Discord channel ID
/// </summary>
public long DiscordChannelId { get; set; }
public long? DiscordChannelId { get; set; }
/// <summary>
/// If the <see cref="ChatChannel"/> is an admin channel
/// </summary>
public bool IsAdminChannel { get; set; }
/// <summary>
/// If the <see cref="ChatChannel"/> is a watchdog channel
/// </summary>
public bool IsWatchdogChannel { get; set; }
}
}
@@ -0,0 +1,17 @@
namespace Tgstation.Server.Api.Models
{
/// <summary>
/// Represents a chat service provider
/// </summary>
public enum ChatProvider
{
/// <summary>
/// Internet relay chat
/// </summary>
Irc,
/// <summary>
/// Superior chat service
/// </summary>
Discord
}
}
@@ -6,22 +6,10 @@ namespace Tgstation.Server.Api.Models
/// <inheritdoc />
public sealed class ChatSettings : Internal.ChatSettings
{
/// <summary>
/// If the IRC connection is established
/// </summary>
[Permissions(DenyWrite = true)]
bool IrcConnected { get; set; }
/// <summary>
/// If the Discord connection is established
/// </summary>
[Permissions(DenyWrite = true)]
bool DiscordConnected { get; set; }
/// <summary>
/// Channels the Discord bot should listen/announce in
/// </summary>
[Permissions(WriteRight = ChatSettingsRights.SetChannels)]
[Permissions(WriteRight = ChatSettingsRights.WriteChannels)]
public List<ChatChannel> Channels { get; set; }
}
}
@@ -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
/// <summary>
/// Manage the server chat bots
/// </summary>
[Model(RightsType.ChatSettings, RequiresInstance = true)]
[Model(RightsType.ChatSettings, RequiresInstance = true, CanList = true, CanCrud = true, ReadRight = ChatSettingsRights.Read)]
public class ChatSettings
{
/// <summary>
/// If the IRC client is enabled
/// The settings id
/// </summary>
[Permissions(WriteRight = ChatSettingsRights.SetIrcEnabled)]
public bool IrcEnabled { get; set; }
[Permissions(DenyWrite = true)]
public long Id { get; set; }
/// <summary>
/// The IRC server name
/// The name of the connection
/// </summary>
[Permissions(ReadRight = ChatSettingsRights.SetIrcSettings, WriteRight = ChatSettingsRights.SetIrcSettings)]
[Permissions(WriteRight = ChatSettingsRights.WriteName)]
[Required]
public string IrcHost { get; set; }
public string Name { get; set; }
/// <summary>
/// The IRC server port
/// If the connection is enabled
/// </summary>
[Permissions(ReadRight = ChatSettingsRights.SetIrcSettings, WriteRight = ChatSettingsRights.SetIrcSettings)]
public ushort IrcPort { get; set; }
[Permissions(WriteRight = ChatSettingsRights.WriteEnabled)]
public bool? Enabled { get; set; }
/// <summary>
/// The IRC server NickServ password
/// The <see cref="ChatProvider"/> used for the connection
/// </summary>
[Permissions(ReadRight = ChatSettingsRights.SetIrcSettings, WriteRight = ChatSettingsRights.SetIrcSettings)]
public string IrcNickServPassword { get; set; }
[Permissions(WriteRight = ChatSettingsRights.WriteProvider)]
public ChatProvider? Provider { get; set; }
/// <summary>
/// If the Discord bot is enabled
/// The information used to connect to the <see cref="Provider"/>
/// </summary>
[Permissions(WriteRight = ChatSettingsRights.SetDiscordEnabled)]
public bool DiscordEnabled { get; set; }
/// <summary>
/// The Discord bot token
/// </summary>
[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; }
}
}
@@ -13,24 +13,40 @@ namespace Tgstation.Server.Api.Rights
/// </summary>
None = 0,
/// <summary>
/// User can enable/disable the IRC client
/// User can change <see cref="Models.Internal.ChatSettings.Enabled"/>
/// </summary>
SetIrcEnabled = 1,
WriteEnabled = 1,
/// <summary>
/// User can change the IRC settings
/// User can change <see cref="Models.Internal.ChatSettings.Provider"/>
/// </summary>
SetIrcSettings = 2,
WriteProvider = 2,
/// <summary>
/// User can change the chat channels
/// User can change <see cref="Models.ChatSettings.Channels"/>
/// </summary>
SetChannels = 4,
WriteChannels = 4,
/// <summary>
/// User can enable/disable the Discord bot
/// User can change <see cref="Models.Internal.ChatSettings.ConnectionString"/>
/// </summary>
SetDiscordEnabled = 8,
WriteConnectionString = 8,
/// <summary>
/// User can change the Discord settings
/// User can read <see cref="Models.Internal.ChatSettings.ConnectionString"/>
/// </summary>
SetDiscordSettings = 16,
ReadConnectionString = 16,
/// <summary>
/// User can read all chat settings except <see cref="Models.Internal.ChatSettings.ConnectionString"/>
/// </summary>
Read = 32,
/// <summary>
/// User can change <see cref="Models.Internal.ChatSettings.Name"/>
/// </summary>
WriteName = 32,
/// <summary>
/// User can create new <see cref="Models.ChatSettings"/>
/// </summary>
Create = 64,
/// <summary>
/// User can delete <see cref="Models.ChatSettings"/>
/// </summary>
Delete = 128
}
}
@@ -18,7 +18,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="2.6.0" />
<PackageReference Include="Octokit" Version="0.29.0" />
<PackageReference Include="System.Runtime.Loader" Version="4.3.0" />
</ItemGroup>
@@ -0,0 +1,34 @@
namespace Tgstation.Server.Host.Components.Chat
{
/// <summary>
/// Represents a <see cref="Providers.IProvider"/> channel
/// </summary>
public sealed class Channel
{
/// <summary>
/// The <see cref="Providers.IProvider"/> channel Id.
/// </summary>
/// <remarks><see cref="Chat"/> remaps this to an internal id using <see cref="ChannelMapping"/></remarks>
public long Id { get; set; }
/// <summary>
/// The user friendly name of the <see cref="Channel"/>
/// </summary>
public string FriendlyName { get; set; }
/// <summary>
/// The name of the connection the <see cref="Channel"/> belongs to
/// </summary>
public string ConnectionName { get; set; }
/// <summary>
/// If this is considered a channel for admin commands
/// </summary>
public bool IsAdmin { get; set; }
/// <summary>
/// If this is a 1-to-1 chat channel
/// </summary>
public bool IsPrivate { get; set; }
}
}
@@ -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; }
}
}
@@ -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
{
/// <inheritdoc />
sealed class Chat : IChat
{
/// <summary>
/// The <see cref="IProviderFactory"/> for the <see cref="Chat"/>
/// </summary>
readonly IProviderFactory providerFactory;
/// <summary>
/// The <see cref="IIOManager"/> for the <see cref="Chat"/>
/// </summary>
readonly IIOManager ioManager;
/// <summary>
/// <see cref="Command"/>s that never change
/// </summary>
readonly IReadOnlyList<Command> builtinCommands;
/// <summary>
/// Map of <see cref="IProvider"/>s in use, keyed by <see cref="ChatSettings.Id"/>
/// </summary>
readonly Dictionary<long, IProvider> providers;
/// <summary>
/// Map of <see cref="Channel.Id"/>s to <see cref="ChannelMapping"/>s
/// </summary>
readonly Dictionary<long, ChannelMapping> mappedChannels;
/// <summary>
/// The active <see cref="IJsonTrackingContext"/>s for the <see cref="Chat"/>
/// </summary>
readonly List<IJsonTrackingContext> trackingContexts;
/// <summary>
/// The <see cref="ICustomCommandHandler"/> for the <see cref="Chat"/>
/// </summary>
ICustomCommandHandler customCommandHandler;
/// <summary>
/// Used for remapping <see cref="Channel.Id"/>s
/// </summary>
long channelIdCounter;
/// <summary>
/// If <see cref="StartAsync(CancellationToken)"/> has been called
/// </summary>
bool started;
/// <summary>
/// Construct a <see cref="Chat"/>
/// </summary>
/// <param name="providerFactory">The value of <see cref="providerFactory"/></param>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="commandFactory">The <see cref="ICommandFactory"/> used to populate <see cref="builtinCommands"/></param>
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<long, IProvider>();
mappedChannels = new Dictionary<long, ChannelMapping>();
trackingContexts = new List<IJsonTrackingContext>();
channelIdCounter = 1;
}
/// <inheritdoc />
public void Dispose()
{
foreach (var I in providers)
I.Value.Dispose();
}
/// <summary>
/// Remove a <see cref="IProvider"/> from <see cref="providers"/> and <see cref="mappedChannels"/> optionally updating the <see cref="trackingContexts"/> as well
/// </summary>
/// <param name="connectionId">The <see cref="ChatSettings.Id"/> of the <see cref="IProvider"/> to delete</param>
/// <param name="updateTrackings">If <see cref="trackingContexts"/> should be update</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="IProvider"/> being removed if it exists, <see langword="false"/> otherwise</returns>
async Task<IProvider> 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;
}
/// <inheritdoc />
public async Task ChangeChannels(long connectionId, IEnumerable<Api.Models.ChatChannel> 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);
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public Task SendMessage(string message, IEnumerable<long> 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);
}));
}
/// <inheritdoc />
public Task SendWatchdogMessage(string message, CancellationToken cancellationToken)
{
List<long> 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);
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Connect(cancellationToken))).ConfigureAwait(false);
started = true;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Disconnect(cancellationToken)));
/// <inheritdoc />
public async Task<IJsonTrackingContext> 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;
}
/// <inheritdoc />
public bool Connected(long connectionId)
{
lock (providers)
return providers.TryGetValue(connectionId, out var provider) && provider.Connected;
}
/// <inheritdoc />
public void RegisterCommandHandler(ICustomCommandHandler customCommandHandler)
{
if (this.customCommandHandler != null)
throw new InvalidOperationException("RegisterCommandHandler() already called!");
this.customCommandHandler = customCommandHandler ?? throw new ArgumentNullException(nameof(customCommandHandler));
}
/// <inheritdoc />
public Task DeleteConnection(long connectionId, CancellationToken cancellationToken) => RemoveProvider(connectionId, true, cancellationToken);
}
}
@@ -1,10 +0,0 @@
using System.Collections.Generic;
namespace Tgstation.Server.Host.Components.Chat
{
sealed class ChatResponse
{
public string Message { get; set; }
public List<long> ChannelIds { get; set; }
}
}
@@ -0,0 +1,29 @@
namespace Tgstation.Server.Host.Components.Chat.Commands
{
/// <summary>
/// Represents a command that can be invoked by talking to chat bots
/// </summary>
public abstract class Command
{
/// <summary>
/// The text to invoke the command. May not be "?" or "help" (case-insensitive)
/// </summary>
public string Name { get; set; }
/// <summary>
/// The help text to display when queires are made about the command
/// </summary>
public string HelpText { get; set; }
/// <summary>
/// If the command should only be available to <see cref="User"/>s who's <see cref="User.Channel"/> has <see cref="Channel.IsAdmin"/> set
/// </summary>
public bool AdminOnly { get; set; }
/// <summary>
/// Invoke the <see cref="Command"/>
/// </summary>
/// <param name="arguments">The text after <see cref="Name"/> with leading whitespace trimmed</param>
public abstract void Invoke(string arguments);
}
}
@@ -0,0 +1,33 @@
using System;
namespace Tgstation.Server.Host.Components.Chat.Commands
{
/// <summary>
/// Represents a command made from DM code
/// </summary>
public sealed class CustomCommand : Command
{
/// <summary>
/// The <see cref="ICustomCommandHandler"/> for the <see cref="CustomCommand"/>
/// </summary>
ICustomCommandHandler handler;
/// <summary>
/// Set a new <paramref name="handler"/>
/// </summary>
/// <param name="handler">The value of <see cref="handler"/></param>
public void SetHandler(ICustomCommandHandler handler)
{
if (this.handler != null)
throw new InvalidOperationException("SetHandler() already called!");
this.handler = handler ?? throw new ArgumentNullException(nameof(handler));
}
/// <inheritdoc />
public override void Invoke(string arguments)
{
if (handler == null)
throw new InvalidOperationException("SetHandler() has not been called!");
}
}
}
@@ -10,33 +10,45 @@ namespace Tgstation.Server.Host.Components.Chat
/// <summary>
/// For managing connected chat services
/// </summary>
public interface IChat : IHostedService
public interface IChat : IHostedService, IDisposable
{
/// <summary>
/// If the IRC client is connected
/// If a given set of <see cref="ChatSettings"/> is connected
/// </summary>
bool IrcConnected { get; }
/// <param name="connectionId">The <see cref="ChatSettings.Id"/> of the connection</param>
/// <returns><see langword="true"/> if it is connected, <see langword="false"/> otherwise</returns>
bool Connected(long connectionId);
/// <summary>
/// If the Discord client is connected
/// Registers a <paramref name="customCommandHandler"/> to use
/// </summary>
bool DiscordConnected { get; }
/// <param name="customCommandHandler">A <see cref="ICustomCommandHandler"/></param>
void RegisterCommandHandler(ICustomCommandHandler customCommandHandler);
/// <summary>
/// Change chat settings
/// Change chat settings. If the <see cref="ChatSettings.Id"/> is not currently in use, a new connection will be made instead
/// </summary>
/// <param name="newSettings">The new <see cref="ChatSettings"/></param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
/// <returns>A <see cref="Task"/> representing the running operation. Will complete immediately if the <see cref="ChatSettings.Enabled"/> property of <paramref name="newSettings"/> is <see langword="false"/></returns>
Task ChangeSettings(ChatSettings newSettings, CancellationToken cancellationToken);
/// <summary>
/// Disconnects and deletes a given connection
/// </summary>
/// <param name="connectionId">The <see cref="ChatSettings.Id"/> of the connection</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task DeleteConnection(long connectionId, CancellationToken cancellationToken);
/// <summary>
/// Change chat channels
/// </summary>
/// <param name="connectionId">The <see cref="ChatSettings.Id"/> of the connection</param>
/// <param name="newChannels">An <see cref="IEnumerable{T}"/> of the new list of <see cref="Api.Models.ChatChannel"/>s</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task ChangeChannels(IEnumerable<Api.Models.ChatChannel> newChannels, CancellationToken cancellationToken);
Task ChangeChannels(long connectionId, IEnumerable<Api.Models.ChatChannel> newChannels, CancellationToken cancellationToken);
/// <summary>
/// Send a chat <paramref name="message"/> to a given set of <paramref name="channelIds"/>
@@ -63,6 +75,6 @@ namespace Tgstation.Server.Host.Components.Chat
/// <param name="commandsJsonName">The name of the chat commands json</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IDisposable"/> tied to the lifetime of the json trackings</returns>
Task<IChatJsonTrackingContext> TrackJsons(string basePath, string channelsJsonName, string commandsJsonName, CancellationToken cancellationToken);
Task<IJsonTrackingContext> TrackJsons(string basePath, string channelsJsonName, string commandsJsonName, CancellationToken cancellationToken);
}
}
@@ -1,11 +0,0 @@
using System;
namespace Tgstation.Server.Host.Components.Chat
{
/// <summary>
/// Represents a tracking of dynamic chat json files
/// </summary>
public interface IChatJsonTrackingContext : IDisposable
{
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
using Tgstation.Server.Host.Components.Chat.Commands;
namespace Tgstation.Server.Host.Components.Chat
{
interface ICommandFactory
{
IReadOnlyList<Command> GenerateCommands();
}
}
@@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Host.Components.Chat
{
/// <summary>
/// Handles <see cref="Commands.Command"/> that map to those defined in a <see cref="IJsonTrackingContext"/>
/// </summary>
public interface ICustomCommandHandler
{
/// <summary>
/// Handle a chat command
/// </summary>
/// <param name="commandName">The command name</param>
/// <param name="arguments">Everything typed after <paramref name="commandName"/> minus leading spaces</param>
/// <param name="sender">The sending <see cref="User"/></param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the response text to send back</returns>
Task<string> HandleChatCommand(string commandName, string arguments, User sender, CancellationToken cancellationToken);
}
}
@@ -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
{
/// <summary>
/// Represents a tracking of dynamic chat json files
/// </summary>
public interface IJsonTrackingContext : IDisposable
{
/// <summary>
/// Read <see cref="CustomCommand"/>s from the <see cref="IJsonTrackingContext"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyList{T}"/> of <see cref="CustomCommand"/>s in the <see cref="IJsonTrackingContext"/></returns>
Task<IReadOnlyList<CustomCommand>> GetCustomCommands(CancellationToken cancellationToken);
/// <summary>
/// Writes information about connected <paramref name="channels"/> to the <see cref="IJsonTrackingContext"/>
/// </summary>
/// <param name="channels">The <see cref="Channel"/>s to write out</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task SetChannels(IEnumerable<Channel> channels, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,18 @@
using Tgstation.Server.Api.Models.Internal;
using Tgstation.Server.Host.Components.Chat.Providers;
namespace Tgstation.Server.Host.Components.Chat
{
/// <summary>
/// Factory for <see cref="IProvider"/>s
/// </summary>
interface IProviderFactory
{
/// <summary>
/// Create a <see cref="IProvider"/>
/// </summary>
/// <param name="settings">The <see cref="ChatSettings"/> for the new provider</param>
/// <returns>A new <see cref="IProvider"/></returns>
IProvider CreateProvider(ChatSettings settings);
}
}
@@ -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
{
/// <inheritdoc />
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);
}
/// <inheritdoc />
public void Dispose() => onDispose();
/// <inheritdoc />
public async Task<IReadOnlyList<CustomCommand>> GetCustomCommands(CancellationToken cancellationToken)
{
try
{
var resultBytes = await ioManager.ReadAllBytes(commandsPath, cancellationToken).ConfigureAwait(false);
var resultJson = Encoding.UTF8.GetString(resultBytes);
var result = JsonConvert.DeserializeObject<List<CustomCommand>>(resultJson);
foreach (var I in result)
I.SetHandler(customCommandHandler);
return result;
}
catch
{
return new List<CustomCommand>();
}
}
/// <inheritdoc />
public async Task SetChannels(IEnumerable<Channel> channels, CancellationToken cancellationToken)
{
using (await SemaphoreSlimContext.Lock(channelsSemaphore, cancellationToken).ConfigureAwait(false))
await ioManager.WriteAllBytes(channelsPath, Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(channels)), cancellationToken).ConfigureAwait(false);
}
}
}
@@ -0,0 +1,8 @@
namespace Tgstation.Server.Host.Components.Chat.Providers
{
sealed class Message
{
string Content { get; set; }
User User { get; set; }
}
}
@@ -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
{
/// <inheritdoc />
sealed class ProviderFactory : IProviderFactory
{
/// <inheritdoc />
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));
}
}
}
}
@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace Tgstation.Server.Host.Components.Chat.Providers
{
/// <summary>
/// For interacting with a chat service
/// </summary>
interface IProvider : IDisposable
{
/// <summary>
/// If the <see cref="IProvider"/>
/// </summary>
bool Connected { get; }
/// <summary>
/// The <see cref="string"/> that indicates the <see cref="IProvider"/> was mentioned
/// </summary>
string BotMention { get; }
/// <summary>
/// Get a <see cref="Task{TResult}"/> resulting in the next <see cref="Message"/> the <see cref="IProvider"/> recieves or <see langword="null"/> on a disconnect
/// </summary>
Task<Message> NextMessage { get; }
/// <summary>
/// Attempt to connect the <see cref="IProvider"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in <see langword="true"/> on success, <see langword="false"/> otherwise</returns>
Task<bool> Connect(CancellationToken cancellationToken);
/// <summary>
/// Gracefully disconnects the provider. Implies a call to <see cref="IDisposable.Dispose"/>
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task Disconnect(CancellationToken cancellationToken);
/// <summary>
/// Get the <see cref="Channel"/>s for given <paramref name="channels"/>
/// </summary>
/// <param name="channels">The <see cref="Api.Models.ChatChannel"/>s to map</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a <see cref="IReadOnlyList{T}"/> of the <see cref="Channel"/>s representing <paramref name="channels"/></returns>
Task<IReadOnlyList<Channel>> MapChannels(IEnumerable<Api.Models.ChatChannel> channels, CancellationToken cancellationToken);
/// <summary>
/// Send a message to the <see cref="IProvider"/>
/// </summary>
/// <param name="channelId">The <see cref="Channel.Id"/> to send to</param>
/// <param name="message">The message contents</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
Task SendMessage(long channelId, string message, CancellationToken cancellationToken);
}
}
@@ -0,0 +1,20 @@
using System.Collections.Generic;
namespace Tgstation.Server.Host.Components.Chat
{
/// <summary>
/// Represents a chat message requested by DD
/// </summary>
sealed class Response
{
/// <summary>
/// The message string
/// </summary>
public string Message { get; set; }
/// <summary>
/// The list of internal channel ids to send <see cref="Message"/> to
/// </summary>
public List<long> ChannelIds { get; set; }
}
}
@@ -0,0 +1,13 @@
namespace Tgstation.Server.Host.Components.Chat
{
/// <summary>
///
/// </summary>
public sealed class User
{
long Id { get; set; }
string FriendlyName { get; set; }
string Mention { get; set; }
Channel Channel { get; set; }
}
}
@@ -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 <see cref="IApplication"/> for <see cref="DreamMaker"/>
/// </summary>
readonly IApplication application;
/// <summary>
/// The <see cref="IEventConsumer"/> for <see cref="DreamMaker"/>
/// </summary>
readonly IEventConsumer eventConsumer;
/// <summary>
/// The <see cref="ILogger"/> for <see cref="DreamMaker"/>
/// </summary>
readonly ILogger<DreamMaker> logger;
/// <summary>
/// Construct <see cref="DreamMaker"/>
@@ -70,8 +79,9 @@ namespace Tgstation.Server.Host.Components
/// <param name="sessionControllerFactory">The value of <see cref="sessionControllerFactory"/></param>
/// <param name="compileJobConsumer">The value of <see cref="compileJobConsumer"/></param>
/// <param name="application">The value of <see cref="application"/></param>
///
public DreamMaker(IByond byond, IIOManager ioManager, IConfiguration configuration, ISessionControllerFactory sessionControllerFactory, ICompileJobConsumer compileJobConsumer, IApplication application)
/// <param name="eventConsumer">The value of <see cref="eventConsumer"/></param>
/// <param name="logger">The value of <see cref="logger"/></param>
public DreamMaker(IByond byond, IIOManager ioManager, IConfiguration configuration, ISessionControllerFactory sessionControllerFactory, ICompileJobConsumer compileJobConsumer, IApplication application, IEventConsumer eventConsumer, ILogger<DreamMaker> 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));
}
/// <summary>
@@ -86,9 +98,10 @@ namespace Tgstation.Server.Host.Components
/// </summary>
/// <param name="timeout">The timeout in seconds for validation</param>
/// <param name="job">The <see cref="Models.CompileJob"/> for the operation</param>
/// <param name="byondLock">The current <see cref="IByondExecutableLock"/></param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in <see langword="true"/> if the DMAPI was successfully validated, <see langword="false"/> otherwise</returns>
async Task<bool> VerifyApi(int timeout, Models.CompileJob job, CancellationToken cancellationToken)
async Task<bool> 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
/// </summary>
/// <param name="dreamMakerPath">The path to the DreamMaker executable</param>
/// <param name="job">The <see cref="Host.Models.CompileJob"/> for the operation</param>
/// <param name="job">The <see cref="Models.CompileJob"/> for the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
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
/// <summary>
/// Adds server side includes to the .dme being compiled
/// </summary>
/// <param name="job">The <see cref="Host.Models.CompileJob"/> for the operation</param>
/// <param name="job">The <see cref="Models.CompileJob"/> for the operation</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task"/> representing the running operation</returns>
async Task ModifyDme(Models.CompileJob job, CancellationToken cancellationToken)
@@ -211,6 +224,8 @@ namespace Tgstation.Server.Host.Components
/// <inheritdoc />
public async Task<Models.CompileJob> Compile(string projectName, int apiValidateTimeout, IRepository repository, CancellationToken cancellationToken)
{
logger.LogTrace("Begin Compile");
await eventConsumer.HandleEvent(EventType.CompileStart, new List<string>{ 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<string> { 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;
@@ -8,97 +8,65 @@
/// <summary>
/// Parameters: Reference name, commit sha
/// </summary>
RepoResetOrigin,
RepoResetOrigin = 0,
/// <summary>
/// Parameters: Reference name, commit sha
/// </summary>
RepoCheckout,
RepoCheckout = 1,
/// <summary>
/// No parameters
/// </summary>
RepoFetch,
RepoFetch = 2,
/// <summary>
/// Parameters: Comma separated list in form of "#{Pull Request Number} @ {7 character SHA}
/// </summary>
RepoMergePullRequests,
RepoMergePullRequests = 3,
/// <summary>
/// Parameters: Current version, new version
/// </summary>
ByondChangeStart,
ByondChangeStart = 4,
/// <summary>
/// No parameters
/// </summary>
ByondChangeCancelled,
ByondChangeCancelled = 5,
/// <summary>
/// Parameters: Error string
/// </summary>
ByondFail,
ByondFail = 6,
/// <summary>
/// No parameters
/// </summary>
ByondStageComplete,
ByondStageComplete = 7,
/// <summary>
/// No parameters
/// </summary>
ByondChangeComplete,
ByondChangeComplete = 8,
/// <summary>
/// Parameters: Commit sha, parameter of <see cref="RepoMergePullRequests"/>
/// Parameters: Origin commit sha
/// </summary>
CompileStart,
CompileStart = 9,
/// <summary>
/// No parameters
/// </summary>
CompileCancelled,
CompileCancelled = 10,
/// <summary>
/// Parameters: Error string
/// Parameters: "1" if compile succeeded and api validation failed, "0" otherwise
/// </summary>
CompileFailure,
CompileFailure = 11,
/// <summary>
/// No parameters
/// </summary>
CompileComplete,
/// <summary>
/// Parameters: Access token
/// </summary>
DDLaunched,
CompileComplete = 12,
/// <summary>
/// Parameters: Exit code
/// </summary>
DDCrash,
DDOtherCrash = 13,
/// <summary>
/// No parameters
/// </summary>
DDExit,
/// <summary>
/// Parameters: Exit code
/// </summary>
DDOtherCrash,
/// <summary>
/// No parameters
/// </summary>
DDOtherExit,
/// <summary>
/// No parameters
/// </summary>
DDRestart,
/// <summary>
/// No parameters
/// </summary>
DDBeginGracefulRestart,
/// <summary>
/// No parameters
/// </summary>
DDBeginGracefulShutdown,
/// <summary>
/// No parameters
/// </summary>
DDCancelGraceful,
/// <summary>
/// No parameters
/// </summary>
DDTerminated,
DDOtherExit = 14,
}
}
@@ -52,7 +52,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <summary>
/// Sends a command to DreamDaemon through /world/Topic()
/// </summary>
/// <param name="command">The command to send</param>
/// <param name="command">The sanitized command to send</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the result of /world/Topic()</returns>
Task<string> SendCommand(string command, CancellationToken cancellationToken);
@@ -14,12 +14,13 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// </summary>
/// <param name="launchParameters">The <see cref="DreamDaemonLaunchParameters"/> to use</param>
/// <param name="dmbProvider">The <see cref="IDmbProvider"/> to use</param>
/// <param name="currentByondLock">The current <see cref="IByondExecutableLock"/> if any</param>
/// <param name="primaryPort">If the <see cref="DreamDaemonLaunchParameters.PrimaryPort"/> of <paramref name="launchParameters"/> should be used</param>
/// <param name="primaryDirectory">If the <see cref="IDmbProvider.PrimaryDirectory"/> of <paramref name="dmbProvider"/> should be used</param>
/// <param name="apiValidate">If the <see cref="ISessionController"/> should only validate the DMAPI then exit</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in a new <see cref="ISessionController"/></returns>
Task<ISessionController> LaunchNew(DreamDaemonLaunchParameters launchParameters, IDmbProvider dmbProvider, bool primaryPort, bool primaryDirectory, bool apiValidate, CancellationToken cancellationToken);
Task<ISessionController> LaunchNew(DreamDaemonLaunchParameters launchParameters, IDmbProvider dmbProvider, IByondExecutableLock currentByondLock, bool primaryPort, bool primaryDirectory, bool apiValidate, CancellationToken cancellationToken);
/// <summary>
/// Create a <see cref="ISessionController"/> from an existing DreamDaemon instance
@@ -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;
/// <summary>
/// The <see cref="IChatJsonTrackingContext"/> for the <see cref="SessionController"/>
/// The <see cref="IJsonTrackingContext"/> for the <see cref="SessionController"/>
/// </summary>
readonly IChatJsonTrackingContext chatJsonTrackingContext;
readonly IJsonTrackingContext chatJsonTrackingContext;
/// <summary>
/// The <see cref="IChat"/> for the <see cref="SessionController"/>
/// </summary>
readonly IChat chat;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="SessionController"/>
/// </summary>
readonly ILogger<SessionController> logger;
/// <summary>
/// The <see cref="TaskCompletionSource{TResult}"/> <see cref="SetPortImpl(ushort, CancellationToken)"/> waits on when DreamDaemon currently has it's ports closed
/// </summary>
@@ -147,15 +153,17 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="interopRegistrar">The <see cref="IInteropRegistrar"/> used to construct <see cref="interopContext"/></param>
/// <param name="chat">The value of <see cref="chat"/></param>
/// <param name="chatJsonTrackingContext">The value of <see cref="chatJsonTrackingContext"/></param>
public SessionController(ReattachInformation reattachInformation, ISession session, IByondTopicSender byondTopicSender, IInteropRegistrar interopRegistrar, IChatJsonTrackingContext chatJsonTrackingContext, IChat chat)
/// <param name="logger">The value of <see cref="logger"/></param>
public SessionController(ReattachInformation reattachInformation, ISession session, IByondTopicSender byondTopicSender, IInteropRegistrar interopRegistrar, IJsonTrackingContext chatJsonTrackingContext, IChat chat, ILogger<SessionController> 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
}
/// <inheritdoc />
public Task<string> 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<string> 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<bool> 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<bool> 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;
/// <inheritdoc />
public async Task<bool> ClosePort(CancellationToken cancellationToken)
@@ -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
/// </summary>
readonly IChat chat;
/// <summary>
/// The <see cref="ILoggerFactory"/> for the <see cref="SessionControllerFactory"/>
/// </summary>
readonly ILoggerFactory loggerFactory;
/// <summary>
/// Construct a <see cref="SessionControllerFactory"/>
/// </summary>
@@ -73,7 +79,8 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="instance">The value of <see cref="instance"/></param>
/// <param name="ioManager">The value of <see cref="ioManager"/></param>
/// <param name="chat">The value of <see cref="chat"/></param>
public SessionControllerFactory(IExecutor executor, IByond byond, IByondTopicSender byondTopicSender, IInteropRegistrar interopRegistrar, ICryptographySuite cryptographySuite, IApplication application, IInstance instance, IIOManager ioManager, IChat chat)
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/></param>
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));
}
/// <inheritdoc />
public async Task<ISessionController> LaunchNew(DreamDaemonLaunchParameters launchParameters, IDmbProvider dmbProvider, bool primaryPort, bool primaryDirectory, bool apiValidate, CancellationToken cancellationToken)
public async Task<ISessionController> 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<SessionController>());
}
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<SessionController>());
}
catch
{
@@ -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
{
/// <inheritdoc />
sealed class Watchdog : IWatchdog, IEventConsumer
sealed class Watchdog : IWatchdog, IEventConsumer, ICustomCommandHandler
{
/// <summary>
/// The time in milliseconds to wait from starting <see cref="alphaServer"/> to start <see cref="bravoServer"/>. Does not take responsiveness into account
@@ -36,7 +38,7 @@ namespace Tgstation.Server.Host.Components.Watchdog
public DreamDaemonLaunchParameters LastLaunchParameters { get; private set; }
/// <inheritdoc />
public RebootState? RebootState => Running ? (RebootState?)(AlphaIsActive ? alphaServer?.RebootState : bravoServer?.RebootState) : null;
public RebootState? RebootState => Running ? (AlphaIsActive ? alphaServer?.RebootState : bravoServer?.RebootState) : null;
/// <summary>
/// The <see cref="IChat"/> for the <see cref="Watchdog"/>
@@ -68,6 +70,11 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// </summary>
readonly IDatabaseContextFactory databaseContextFactory;
/// <summary>
/// The <see cref="IByondTopicSender"/> for the <see cref="Watchdog"/>
/// </summary>
readonly IByondTopicSender byondTopicSender;
/// <summary>
/// The <see cref="SemaphoreSlim"/> for the <see cref="Watchdog"/>
/// </summary>
@@ -104,10 +111,11 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// <param name="logger">The value of <see cref="logger"/></param>
/// <param name="reattachInfoHandler">The value of <see cref="reattachInfoHandler"/></param>
/// <param name="databaseContextFactory">The value of <see cref="databaseContextFactory"/></param>
/// <param name="byondTopicSender">The value of <see cref="byondTopicSender"/></param>
/// <param name="initialLaunchParameters">The initial value of <see cref="ActiveLaunchParameters"/></param>
/// <param name="instance">The <see cref="Models.Instance"/> containing the value of <see cref="instanceId"/></param>
/// <param name="autoStart">The value of <see cref="autoStart"/></param>
public Watchdog(IChat chat, ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, IServerUpdater serverUpdater, ILogger<Watchdog> logger, IReattachInfoHandler reattachInfoHandler, IDatabaseContextFactory databaseContextFactory, DreamDaemonLaunchParameters initialLaunchParameters, Models.Instance instance, bool autoStart)
public Watchdog(IChat chat, ISessionControllerFactory sessionControllerFactory, IDmbFactory dmbFactory, IServerUpdater serverUpdater, ILogger<Watchdog> 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<ISessionController> 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<ISessionController> 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<ISessionController> 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<LaunchResult> 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
/// <inheritdoc />
public async Task HandleEvent(EventType eventType, IEnumerable<string> 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<Response> responses;
try
{
responses = JsonConvert.DeserializeObject<List<Response>>(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);
}
/// <inheritdoc />
public async Task<string> 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!";
}
}
}
@@ -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
/// <summary>
/// The <see cref="ISessionControllerFactory"/> for the <see cref="WatchdogFactory"/>
/// </summary>
readonly ISessionControllerFactory sessionManagerFactory;
readonly ISessionControllerFactory sessionControllerFactory;
/// <summary>
/// The <see cref="IServerUpdater"/> for the <see cref="WatchdogFactory"/>
@@ -39,6 +40,11 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// </summary>
readonly IDatabaseContextFactory databaseContextFactory;
/// <summary>
/// The <see cref="IByondTopicSender"/> for the <see cref="WatchdogFactory"/>
/// </summary>
readonly IByondTopicSender byondTopicSender;
/// <summary>
/// The <see cref="Models.Instance"/> for the <see cref="WatchdogFactory"/>
/// </summary>
@@ -49,24 +55,26 @@ namespace Tgstation.Server.Host.Components.Watchdog
/// Construct a <see cref="WatchdogFactory"/>
/// </summary>
/// <param name="chat">The value of <see cref="chat"/></param>
/// <param name="sessionManagerFactory">The value of <see cref="sessionManagerFactory"/></param>
/// <param name="sessionControllerFactory">The value of <see cref="sessionControllerFactory"/></param>
/// <param name="serverUpdater">The value of <see cref="serverUpdater"/></param>
/// <param name="loggerFactory">The value of <see cref="loggerFactory"/></param>
/// <param name="reattachInfoHandler">The value of <see cref="reattachInfoHandler"/></param>
/// <param name="databaseContextFactory">The value of <see cref="databaseContextFactory"/></param>
/// <param name="byondTopicSender">The value of <see cref="byondTopicSender"/></param>
/// <param name="instance">The value of <see cref="instance"/></param>
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));
}
/// <inheritdoc />
public IWatchdog CreateWatchdog(IDmbFactory dmbFactory, DreamDaemonSettings settings) => new Watchdog(chat, sessionManagerFactory, dmbFactory, serverUpdater, loggerFactory.CreateLogger<Watchdog>(), reattachInfoHandler, databaseContextFactory, settings, instance, settings.AutoStart.Value);
public IWatchdog CreateWatchdog(IDmbFactory dmbFactory, DreamDaemonSettings settings) => new Watchdog(chat, sessionControllerFactory, dmbFactory, serverUpdater, loggerFactory.CreateLogger<Watchdog>(), reattachInfoHandler, databaseContextFactory, byondTopicSender, settings, instance, settings.AutoStart.Value);
}
}
@@ -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
{
/// <summary>
/// <see cref="ModelController{TModel}"/> for managing <see cref="Api.Models.ChatSettings"/>
/// </summary>
[TgsAuthorize]
public sealed class ChatController : ModelController<Api.Models.ChatSettings>
{
/// <summary>
/// The <see cref="IInstanceManager"/> for the <see cref="ChatController"/>
/// </summary>
readonly IInstanceManager instanceManager;
/// <summary>
/// Construct a <see cref="ChatController"/>
/// </summary>
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="ApiController"/></param>
/// <param name="authenticationContextFactory">The <see cref="IAuthenticationContextFactory"/> for the <see cref="ApiController"/></param>
/// <param name="instanceManager">The value of <see cref="instanceManager"/></param>
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
};
/// <inheritdoc />
[TgsAuthorize(ChatSettingsRights.Create)]
public override async Task<IActionResult> 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<Models.ChatChannel>(),
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);
}
/// <inheritdoc />
[TgsAuthorize(ChatSettingsRights.Delete)]
public override async Task<IActionResult> 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();
}
/// <inheritdoc />
[TgsAuthorize(ChatSettingsRights.Read)]
public override async Task<IActionResult> 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);
}
/// <inheritdoc />
[TgsAuthorize(ChatSettingsRights.WriteChannels | ChatSettingsRights.WriteConnectionString | ChatSettingsRights.WriteEnabled | ChatSettingsRights.WriteName | ChatSettingsRights.WriteProvider)]
public override async Task<IActionResult> 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<T>(Expression<Func<Api.Models.Internal.ChatSettings, T>> 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();
}
}
}
@@ -18,10 +18,10 @@ using Tgstation.Server.Host.Security;
namespace Tgstation.Server.Host.Controllers
{
/// <summary>
/// <see cref="ModelController{TModel}"/> for managing <see cref="Api.Models.DreamDaemon"/>
/// <see cref="ModelController{TModel}"/> for managing the <see cref="DreamDaemon"/>
/// </summary>
[Route("/" + nameof(DreamDaemon))]
public sealed class DreamDaemonController : ModelController<Api.Models.DreamDaemon>
public sealed class DreamDaemonController : ModelController<DreamDaemon>
{
/// <summary>
/// The <see cref="IJobManager"/> for the <see cref="DreamMakerController"/>
@@ -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();
@@ -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();
}
@@ -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();
}
@@ -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();
@@ -9,7 +9,7 @@
public long Id { get; set; }
/// <summary>
/// The <see cref="ChatSettings.Id"/>
/// The <see cref="Api.Models.Internal.ChatSettings.Id"/>
/// </summary>
public long ChatSettingsId { get; set; }
@@ -5,12 +5,7 @@ namespace Tgstation.Server.Host.Models
{
/// <inheritdoc />
public sealed class ChatSettings : Api.Models.Internal.ChatSettings
{
/// <summary>
/// The row Id
/// </summary>
public long Id { get; set; }
{
/// <summary>
/// The <see cref="Api.Models.Instance.Id"/>
/// </summary>
@@ -52,9 +52,7 @@ namespace Tgstation.Server.Host.Models
/// </summary>
public DbSet<InstanceUser> InstanceUsers { get; set; }
/// <summary>
/// The <see cref="ChatChannel"/>s in the <see cref="DatabaseContext{TParentContext}"/>
/// </summary>
/// <inheritdoc />
public DbSet<ChatChannel> ChatChannels { get; set; }
/// <inheritdoc />
@@ -49,6 +49,11 @@ namespace Tgstation.Server.Host.Models
/// </summary>
DbSet<ChatSettings> ChatSettings { get; set; }
/// <summary>
/// The <see cref="ChatChannel"/> in the <see cref="IDatabaseContext"/>
/// </summary>
DbSet<ChatChannel> ChatChannels { get; set; }
/// <summary>
/// The <see cref="Models.RepositorySettings"/> in the <see cref="IDatabaseContext"/>
/// </summary>
@@ -34,19 +34,21 @@
<ItemGroup>
<PackageReference Include="Byond.TopicSender" Version="1.1.0.1" />
<PackageReference Include="Cyberboss.SmartIrc4net.Standard" Version="0.4.5" />
<PackageReference Include="Discord.Net.WebSocket" Version="1.0.2" />
<PackageReference Include="LibGit2Sharp" Version="0.26.0-preview-0017" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.1.0-preview2-final" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.0-preview2-final" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.1.0-preview2-final" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.0-preview2-final" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.1.0-preview2-final" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.1.0-preview2-final" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.0-preview2-final" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="2.1.0-preview2-final" />
<PackageReference Include="MySql.Data.EntityFrameworkCore" Version="8.0.10-rc" />
<PackageReference Include="Octokit" Version="0.29.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="5.2.1" />
<PackageReference Include="Z.EntityFramework.Plus.EFCore" Version="1.7.17" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="2.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="2.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="2.1.1" />
<PackageReference Include="MySql.Data.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Octokit" Version="0.30.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="5.2.4" />
<PackageReference Include="Z.EntityFramework.Plus.EFCore" Version="1.8.4" />
<PackageReference Include="ZNetCS.AspNetCore.Logging.EntityFrameworkCore" Version="2.0.1" />
</ItemGroup>