1 using Microsoft.Extensions.Logging;
5 using System.Collections.Generic;
9 using System.Threading.Tasks;
20 #pragma warning disable CA1506 23 const string CommonMention =
"!tgs";
58 readonly ILogger<ChatManager>
logger;
138 this.providerFactory = providerFactory ??
throw new ArgumentNullException(nameof(providerFactory));
139 this.ioManager = ioManager ??
throw new ArgumentNullException(nameof(ioManager));
140 this.commandFactory = commandFactory ??
throw new ArgumentNullException(nameof(commandFactory));
141 if (serverControl == null)
142 throw new ArgumentNullException(nameof(serverControl));
143 this.asyncDelayer = asyncDelayer ??
throw new ArgumentNullException(nameof(asyncDelayer));
144 this.loggerFactory = loggerFactory ??
throw new ArgumentNullException(nameof(loggerFactory));
145 this.logger = logger ??
throw new ArgumentNullException(nameof(logger));
146 activeChatBots = initialChatBots?.ToList() ??
throw new ArgumentNullException(nameof(initialChatBots));
150 synchronizationLock =
new object();
152 builtinCommands =
new Dictionary<string, ICommand>();
153 providers =
new Dictionary<long, IProvider>();
154 mappedChannels =
new Dictionary<ulong, ChannelMapping>();
155 trackingContexts =
new List<IChatTrackingContext>();
156 handlerCts =
new CancellationTokenSource();
157 connectionsUpdated =
new TaskCompletionSource<object>();
158 channelIdCounter = 1;
164 logger.LogTrace(
"Disposing...");
165 restartRegistration.Dispose();
166 handlerCts.Dispose();
167 foreach (var I
in providers)
178 async Task<IProvider>
RemoveProvider(
long connectionId,
bool updateTrackings, CancellationToken cancellationToken)
180 logger.LogTrace(
"RemoveProvider {0}...", connectionId);
183 if (!providers.TryGetValue(connectionId, out provider))
185 logger.LogTrace(
"Aborted, no such provider!");
189 Task trackingContextsUpdateTask;
190 lock (mappedChannels)
192 foreach (var I
in mappedChannels.Where(x => x.Value.ProviderId == connectionId).Select(x => x.Key).ToList())
193 mappedChannels.Remove(I);
195 var newMappedChannels = mappedChannels.Select(y => y.Value.Channel).ToList();
198 lock (trackingContexts)
199 trackingContextsUpdateTask = Task.WhenAll(trackingContexts.Select(x => x.UpdateChannels(newMappedChannels, cancellationToken)));
201 trackingContextsUpdateTask = Task.CompletedTask;
204 await trackingContextsUpdateTask.ConfigureAwait(
false);
216 #pragma warning disable CA1502 218 #pragma warning restore CA1502 223 logger.LogTrace(
"Remapping channels for provider reconnection...");
224 IEnumerable<Api.Models.ChatChannel> channelsToMap;
225 lock (activeChatBots)
226 channelsToMap = activeChatBots.FirstOrDefault()?.Channels;
228 if (channelsToMap?.Any() ??
false)
232 providerId = providers.Where(x => x.Value == provider).Select(x => x.Key).First();
233 await ChangeChannels(providerId, channelsToMap, cancellationToken).ConfigureAwait(
false);
242 var providerId = providers.Where(x => x.Value == provider).Select(x => x.Key).First();
243 var enumerable = mappedChannels.Where(x => x.Value.ProviderId == providerId && x.Value.ProviderChannelId == message.
User.
Channel.
RealId);
245 lock (mappedChannels)
249 if (!enumerable.Any())
252 lock (synchronizationLock)
253 newId = channelIdCounter++;
255 "Mapping private channel {0}:{1} as {2}",
261 IsWatchdogChannel =
false,
263 ProviderId = providerId,
274 var mapping = enumerable.First().Value;
281 var splits =
new List<string>(message.
Content.Trim().Split(
' '));
282 var address = splits[0];
283 if (address.Length > 1 && (address.Last() ==
':' || address.Last() ==
','))
284 address = address[0..^1];
286 address = address.ToUpperInvariant();
288 var addressed = address == CommonMention.ToUpperInvariant() || address == provider.
BotMention.ToUpperInvariant();
295 "Start processing command: {0}. User (True provider Id): {1}",
297 JsonConvert.SerializeObject(message.
User));
303 if (splits.Count == 0)
306 await SendMessage(
"Hi!",
new List<ulong> { message.
User.
Channel.
RealId }, cancellationToken).ConfigureAwait(
false);
310 var command = splits[0].ToUpperInvariant();
312 var arguments = String.Join(
" ", splits);
314 ICommand GetCommand(
string commandName)
316 if (!builtinCommands.TryGetValue(commandName, out var handler))
318 handler = trackingContexts
319 .Where(x => x.CustomCommands != null)
320 .SelectMany(x => x.CustomCommands)
321 .Where(x => x.Name.ToUpperInvariant() == commandName)
328 const string UnknownCommandMessage =
"Unknown command! Type '?' or 'help' for available commands.";
330 if (command ==
"HELP" || command ==
"?")
333 if (splits.Count == 0)
335 var allCommands = builtinCommands.Select(x => x.Value).ToList();
336 allCommands.AddRange(
338 .Where(x => x.CustomCommands != null)
340 x => x.CustomCommands));
341 helpText = String.Format(CultureInfo.InvariantCulture,
"Available commands (Type '?' or 'help' and then a command name for more details): {0}", String.Join(
", ", allCommands.Select(x => x.Name)));
345 var helpHandler = GetCommand(splits[0].ToUpperInvariant());
346 if (helpHandler !=
default)
347 helpText = String.Format(CultureInfo.InvariantCulture,
"{0}: {1}{2}", helpHandler.Name, helpHandler.HelpText, helpHandler.AdminOnly ?
" - May only be used in admin channels" : String.Empty);
349 helpText = UnknownCommandMessage;
352 await SendMessage(helpText,
new List<ulong> { message.
User.
Channel.
RealId }, cancellationToken).ConfigureAwait(
false);
356 var commandHandler = GetCommand(command);
358 if (commandHandler ==
default)
360 await SendMessage(UnknownCommandMessage,
new List<ulong> { message.
User.
Channel.
RealId }, cancellationToken).ConfigureAwait(
false);
366 await SendMessage(
"Use this command in an admin channel!",
new List<ulong> { message.
User.
Channel.
RealId }, cancellationToken).ConfigureAwait(
false);
370 var result = await commandHandler.Invoke(arguments, message.
User, cancellationToken).ConfigureAwait(
false);
372 await SendMessage(result,
new List<ulong> { message.
User.
Channel.
RealId }, cancellationToken).ConfigureAwait(
false);
374 catch (OperationCanceledException)
376 logger.LogTrace(
"Command processing canceled!");
382 logger.LogError(
"Error processing chat command: {0}", e);
384 "TGS: Internal error processing command! Check server logs!",
387 .ConfigureAwait(
false);
391 logger.LogTrace(
"Done processing command.");
402 logger.LogTrace(
"Starting processing loop...");
403 var messageTasks =
new Dictionary<IProvider, Task<Message>>();
406 while (!cancellationToken.IsCancellationRequested)
409 foreach (var I
in messageTasks.Where(x => !x.Key.Connected).ToList())
410 messageTasks.Remove(I.Key);
414 lock (synchronizationLock)
415 updatedTask = connectionsUpdated.Task;
417 foreach (var I
in providers)
418 if (I.Value.Connected && !messageTasks.ContainsKey(I.Value))
419 messageTasks.Add(I.Value, I.Value.NextMessage(cancellationToken));
421 if (messageTasks.Count == 0)
423 await asyncDelayer.Delay(TimeSpan.FromSeconds(1), cancellationToken).ConfigureAwait(
false);
428 await Task.WhenAny(updatedTask, Task.WhenAny(messageTasks.Select(x => x.Value))).ConfigureAwait(
false);
431 foreach (var I
in messageTasks.Where(x => x.Value.IsCompleted).ToList())
433 var message = await I.Value.ConfigureAwait(
false);
434 var messageNumber = Interlocked.Increment(ref messagesProcessed);
435 using (LogContext.PushProperty(
"ChatMessage", messageNumber))
436 await ProcessMessage(I.Key, message, cancellationToken).ConfigureAwait(
false);
437 messageTasks.Remove(I.Key);
441 catch (OperationCanceledException)
443 logger.LogTrace(
"Message processing loop cancelled!");
447 logger.LogError(
"Message loop crashed! Exception: {0}", e);
450 logger.LogTrace(
"Leaving message processing loop");
454 public async Task
ChangeChannels(
long connectionId, IEnumerable<Api.Models.ChatChannel> newChannels, CancellationToken cancellationToken)
456 if (newChannels == null)
457 throw new ArgumentNullException(nameof(newChannels));
459 logger.LogTrace(
"ChangeChannels {0}...", connectionId);
460 var provider = await RemoveProvider(connectionId,
false, cancellationToken).ConfigureAwait(
false);
461 if (provider == null)
463 var results = await provider.
MapChannels(newChannels, cancellationToken).ConfigureAwait(
false);
464 lock (activeChatBots)
466 var botToUpdate = activeChatBots.FirstOrDefault(bot => bot.Id == connectionId);
467 if (botToUpdate != null)
468 botToUpdate.Channels = newChannels
469 .Select(apiModel =>
new Models.ChatChannel
471 DiscordChannelId = apiModel.DiscordChannelId,
472 IrcChannel = apiModel.IrcChannel,
473 IsAdminChannel = apiModel.IsAdminChannel,
474 IsUpdatesChannel = apiModel.IsUpdatesChannel,
475 IsWatchdogChannel = apiModel.IsWatchdogChannel,
481 var mappings = Enumerable.Zip(newChannels, results, (x, y) =>
new ChannelMapping 484 IsUpdatesChannel = x.IsUpdatesChannel ==
true,
485 IsAdminChannel = x.IsAdminChannel ==
true,
486 ProviderChannelId = y.RealId,
487 ProviderId = connectionId,
492 lock (synchronizationLock)
494 baseId = channelIdCounter;
495 channelIdCounter += (ulong)results.Count;
498 Task trackingContextUpdateTask;
499 lock (mappedChannels)
502 if (!providers.TryGetValue(connectionId, out
IProvider verify) || verify != provider)
504 foreach (var I
in mappings)
506 var newId = baseId++;
507 logger.LogTrace(
"Mapping channel {0}:{1} as {2}", I.Channel.ConnectionName, I.Channel.FriendlyName, newId);
508 mappedChannels.Add(newId, I);
509 I.Channel.RealId = newId;
512 lock (trackingContexts)
513 trackingContextUpdateTask = Task.WhenAll(
514 trackingContexts.Select(
515 x => x.UpdateChannels(
516 mappedChannels.Select(y => y.Value.Channel).ToList(),
517 cancellationToken)));
520 await trackingContextUpdateTask.ConfigureAwait(
false);
526 if (newSettings == null)
527 throw new ArgumentNullException(nameof(newSettings));
529 logger.LogTrace(
"ChangeSettings...");
532 async Task DisconnectProvider(
IProvider p)
536 await p.
Disconnect(cancellationToken).ConfigureAwait(
false);
548 if (providers.TryGetValue(newSettings.
Id, out provider))
550 providers.Remove(newSettings.
Id);
551 disconnectTask = DisconnectProvider(provider);
554 disconnectTask = Task.CompletedTask;
557 provider = providerFactory.CreateProvider(newSettings);
558 providers.Add(newSettings.
Id, provider);
562 lock (mappedChannels)
563 foreach (var I
in mappedChannels.Where(x => x.Value.ProviderId == newSettings.Id).Select(x => x.Key).ToList())
564 mappedChannels.Remove(I);
566 await disconnectTask.ConfigureAwait(
false);
570 if (newSettings.Enabled.Value)
571 await provider.
Connect(cancellationToken).ConfigureAwait(
false);
572 lock (synchronizationLock)
575 var oldOne = connectionsUpdated;
576 connectionsUpdated =
new TaskCompletionSource<object>();
577 oldOne.SetResult(null);
581 Task reconnectionUpdateTask = Task.CompletedTask;
582 lock (activeChatBots)
584 var originalChatBot = activeChatBots.FirstOrDefault(bot => bot.Id == newSettings.Id);
585 if (originalChatBot != null)
587 if (originalChatBot.ReconnectionInterval != newSettings.ReconnectionInterval)
590 activeChatBots.Remove(originalChatBot);
593 activeChatBots.Add(
new Models.ChatBot
596 ConnectionString = newSettings.ConnectionString,
597 Enabled = newSettings.Enabled,
598 Name = newSettings.Name,
599 ReconnectionInterval = newSettings.ReconnectionInterval,
600 Provider = newSettings.Provider
604 await reconnectionUpdateTask.ConfigureAwait(
false);
608 public Task
SendMessage(
string message, IEnumerable<ulong> channelIds, CancellationToken cancellationToken)
611 throw new ArgumentNullException(nameof(message));
612 if (channelIds == null)
613 throw new ArgumentNullException(nameof(channelIds));
615 logger.LogTrace(
"Chat send \"{0}\" to channels: {1}", message, String.Join(
", ", channelIds));
617 return Task.WhenAll(channelIds.Select(x =>
619 ChannelMapping channelMapping;
620 lock (mappedChannels)
621 if (!mappedChannels.TryGetValue(x, out channelMapping))
622 return Task.CompletedTask;
625 if (!providers.TryGetValue(channelMapping.ProviderId, out provider))
626 return Task.CompletedTask;
627 return provider.SendMessage(channelMapping.ProviderChannelId, message, cancellationToken);
634 List<ulong> wdChannels = null;
635 message = String.Format(CultureInfo.InvariantCulture,
"WD: {0}", message);
638 lock (mappedChannels)
642 wdChannels = mappedChannels.Where(x => x.Value.IsAdminChannel).Select(x => x.Key).ToList();
643 if (wdChannels.Count == 0)
648 wdChannels = mappedChannels.Where(x => x.Value.IsWatchdogChannel).Select(x => x.Key).ToList();
651 return SendMessage(message, wdChannels, cancellationToken);
656 Models.RevisionInformation revisionInformation,
657 Version byondVersion,
658 DateTimeOffset? estimatedCompletionTime,
661 bool localCommitPushed,
662 CancellationToken cancellationToken)
664 List<ulong> wdChannels;
665 lock (mappedChannels)
666 wdChannels = mappedChannels.Where(x => x.Value.IsUpdatesChannel).Select(x => x.Key).ToList();
668 logger.LogTrace(
"Sending deployment message for RevisionInformation: {0}", revisionInformation.Id);
670 var callbacks =
new List<Func<string, string, Task>>();
676 ChannelMapping channelMapping;
677 lock (mappedChannels)
678 if (!mappedChannels.TryGetValue(x, out channelMapping))
682 if (!providers.TryGetValue(channelMapping.ProviderId, out provider))
686 var callback = await provider.SendUpdateMessage(
689 estimatedCompletionTime,
692 channelMapping.ProviderChannelId,
695 .ConfigureAwait(false);
697 callbacks.Add(callback);
702 "Error sending deploy message to provider {0}! Exception: {1}",
703 channelMapping.ProviderId,
707 .ConfigureAwait(
false);
709 return (errorMessage, dreamMakerOutput) => Task.WhenAll(callbacks.Select(x => x(errorMessage, dreamMakerOutput)));
713 public async Task
StartAsync(CancellationToken cancellationToken)
715 foreach (var I
in commandFactory.GenerateCommands())
716 builtinCommands.Add(I.Name.ToUpperInvariant(), I);
717 var initialChatBots = activeChatBots.ToList();
718 await Task.WhenAll(initialChatBots.Select(x => ChangeSettings(x, cancellationToken))).ConfigureAwait(
false);
719 await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Connect(cancellationToken))).ConfigureAwait(
false);
720 await Task.WhenAll(initialChatBots.Select(x => ChangeChannels(x.Id, x.Channels, cancellationToken))).ConfigureAwait(
false);
721 chatHandler = MonitorMessages(handlerCts.Token);
726 public async Task
StopAsync(CancellationToken cancellationToken)
729 if (chatHandler != null)
730 await chatHandler.ConfigureAwait(
false);
731 await Task.WhenAll(providers.Select(x => x.Value).Select(x => x.Disconnect(cancellationToken))).ConfigureAwait(
false);
737 if (customCommandHandler == null)
738 throw new InvalidOperationException(
"RegisterCommandHandler() hasn't been called!");
741 lock (mappedChannels)
743 customCommandHandler,
744 mappedChannels.Select(y => y.Value.Channel),
748 lock (trackingContexts)
749 trackingContexts.Remove(context);
752 lock (trackingContexts)
753 trackingContexts.Add(context);
761 if (this.customCommandHandler != null)
762 throw new InvalidOperationException(
"RegisterCommandHandler() already called!");
763 this.customCommandHandler = customCommandHandler ??
throw new ArgumentNullException(nameof(customCommandHandler));
769 var provider = await RemoveProvider(connectionId,
true, cancellationToken).ConfigureAwait(
false);
770 if (provider != null)
773 await provider.Disconnect(cancellationToken).ConfigureAwait(
false);
782 public Task
HandleRestart(Version updateVersion, CancellationToken cancellationToken)
784 var message = updateVersion == null ?
"TGS: Restart requested..." : String.Format(CultureInfo.InvariantCulture,
"TGS: Updating to version {0}...", updateVersion);
785 List<ulong> wdChannels;
786 lock (mappedChannels)
787 wdChannels = mappedChannels.Select(x => x.Key).ToList();
788 return SendMessage(message, wdChannels, cancellationToken);
bool IsWatchdogChannel
If Channel is a watchdog channel
Represents a mapping of a ChannelRepresentation.RealId
bool IsPrivateChannel
If this is a 1-to-1 chat channel
ulong channelIdCounter
Used for remapping ChannelRepresentation.RealIds
Task SendWatchdogMessage(string message, bool adminOnly, CancellationToken cancellationToken)
Send a chat message to configured watchdog channels
async Task ChangeSettings(ChatBot newSettings, CancellationToken cancellationToken)
Change chat settings. If the ChatBot.Id is not currently in use, a new connection will be made instea...
readonly ICommandFactory commandFactory
The ICommandFactory for the ChatManager
ChatUser User
The ChatUser who sent the Message
Use server authentication
readonly IDictionary< string, ICommand > builtinCommands
Unchanging ICommands in the ChatManager mapped by ICommand.Name
ChatManager(IProviderFactory providerFactory, IIOManager ioManager, ICommandFactory commandFactory, IServerControl serverControl, IAsyncDelayer asyncDelayer, ILoggerFactory loggerFactory, ILogger< ChatManager > logger, IEnumerable< Models.ChatBot > initialChatBots)
Construct a ChatManager
Task Disconnect(CancellationToken cancellationToken)
Gracefully disconnects the provider. Permanently stops the reconnection timer.
readonly IDictionary< long, IProvider > providers
Map of IProviders in use, keyed by ChatBot.Id
ulong RealId
The Providers.IProvider channel Id.
bool IsAdminChannel
If this is considered a channel for admin commands
async Task< IProvider > RemoveProvider(long connectionId, bool updateTrackings, CancellationToken cancellationToken)
Remove a IProvider from providers and mappedChannels optionally updating the trackingContexts as well...
Task HandleRestart(Version updateVersion, CancellationToken cancellationToken)
Handle a restart of the server
Represents a message recieved by a IProvider
async Task DeleteConnection(long connectionId, CancellationToken cancellationToken)
Disconnects and deletes a given connection
For interacting with a chat service
Task chatHandler
The Task that monitors incoming chat messages
readonly IRestartRegistration restartRegistration
The IRestartRegistration for the ChatManager
For waiting asynchronously
Manage the server chat bots
readonly IList< IChatTrackingContext > trackingContexts
The active IChatTrackingContexts for the ChatManager
string Content
The text of the message
For managing connected chat services
string Id
Backing field for RealId. Represented as a string to avoid BYOND percision loss
async Task< Func< string, string, Task > > SendDeploymentMessage(Models.RevisionInformation revisionInformation, Version byondVersion, DateTimeOffset?estimatedCompletionTime, string gitHubOwner, string gitHubRepo, bool localCommitPushed, CancellationToken cancellationToken)
Send the message for a deployment to configured deployment channels.
string FriendlyName
The friendly name of the user
IChatTrackingContext CreateTrackingContext()
Start tracking Commands.CustomCommands and ChannelRepresentations.
Factory for built in ICommands
Handler for server restarts
string ConnectionName
The name of the connection the ChannelRepresentation belongs to
readonly CancellationTokenSource handlerCts
The CancellationTokenSource for chatHandler
ICustomCommandHandler customCommandHandler
The ICustomCommandHandler for the ChangeChannels(long, IEnumerable<Api.Models.ChatChannel>, CancellationToken)
Task< bool > Connect(CancellationToken cancellationToken)
Attempt to connect the IProvider
readonly object synchronizationLock
Used for various lock statements throughout this .
bool started
If StartAsync(CancellationToken) has been called
readonly IIOManager ioManager
The IIOManager for the ChatManager
bool Connected
If the IProvider
async Task ChangeChannels(long connectionId, IEnumerable< Api.Models.ChatChannel > newChannels, CancellationToken cancellationToken)
Change chat channels
string BotMention
The string that indicates the IProvider was mentioned
async Task StopAsync(CancellationToken cancellationToken)
void RegisterCommandHandler(ICustomCommandHandler customCommandHandler)
Registers a customCommandHandler to use
TaskCompletionSource< object > connectionsUpdated
The TaskCompletionSource<TResult> that completes when ChatBots change
async Task StartAsync(CancellationToken cancellationToken)
readonly List< Models.ChatBot > activeChatBots
The active Models.ChatBot for the ChatManager
Handles Commands.ICommands that map to those defined in a IChatTrackingContext
Task SendMessage(string message, IEnumerable< ulong > channelIds, CancellationToken cancellationToken)
Send a chat message to a given set of channelIds
Represents a tracking of dynamic chat json files
readonly ILoggerFactory loggerFactory
The ILoggerFactory for the ChatManager
async Task ProcessMessage(IProvider provider, Message message, CancellationToken cancellationToken)
Processes a message
Interface for using filesystems
readonly IAsyncDelayer asyncDelayer
The IAsyncDelayer for the ChatManager
ChannelRepresentation Channel
The ChannelRepresentation the user spoke from
readonly IDictionary< ulong, ChannelMapping > mappedChannels
Map of ChannelRepresentation.RealIds to ChannelMappings
Represents the lifetime of a IRestartHandler registration
readonly IProviderFactory providerFactory
The IProviderFactory for the ChatManager
IRestartRegistration RegisterForRestart(IRestartHandler handler)
Register a given handler to run before stopping the server for a restart
readonly ILogger< ChatManager > logger
The ILogger for the ChatManager
bool Enabled
If the connection is enabled
async Task MonitorMessages(CancellationToken cancellationToken)
Monitors active providers for new Messages
Represents a command that can be invoked by talking to chat bots
Task< IReadOnlyCollection< ChannelRepresentation > > MapChannels(IEnumerable< Api.Models.ChatChannel > channels, CancellationToken cancellationToken)
Get the ChannelRepresentations for given channels
long messagesProcessed
The number of Messages processed.
Task SetReconnectInterval(uint reconnectInterval)
Set the interval at which the provider tries to reconnect.
Represents a service that may take an updated Host assembly and run it, stopping the current assembly...