Merge branch 'V4' into WatchdogErrorStates

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