tgstation-server 6.19.3
The /tg/station 13 server suite
Loading...
Searching...
No Matches
ChatManager.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Concurrent;
3using System.Collections.Generic;
4using System.Globalization;
5using System.Linq;
6using System.Threading;
7using System.Threading.Tasks;
8
9using Microsoft.Extensions.Logging;
10
11using Newtonsoft.Json;
12
13using Serilog.Context;
14
23
25{
27#pragma warning disable CA1506 // TODO: Decomplexify
29 {
33 public const string CommonMention = "!tgs";
34
38 const string HelpCommandName = "help";
39
44
49
54
58 readonly ILoggerFactory loggerFactory;
59
63 readonly ILogger<ChatManager> logger;
64
68 readonly Dictionary<string, ICommand> builtinCommands;
69
73 readonly Dictionary<long, IProvider> providers;
74
78 readonly ConcurrentDictionary<long, SemaphoreSlim> changeChannelSemaphores;
79
83 readonly Dictionary<ulong, ChannelMapping> mappedChannels;
84
88 readonly List<IChatTrackingContext> trackingContexts;
89
93 readonly List<Models.ChatBot> activeChatBots;
94
98 readonly CancellationTokenSource handlerCts;
99
103 readonly object synchronizationLock;
104
109
114
119
124
128 TaskCompletionSource connectionsUpdated;
129
134
139
152 IServerControl serverControl,
153 ILoggerFactory loggerFactory,
154 ILogger<ChatManager> logger,
155 IEnumerable<Models.ChatBot> initialChatBots)
156 {
157 this.providerFactory = providerFactory ?? throw new ArgumentNullException(nameof(providerFactory));
158 this.commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory));
159 ArgumentNullException.ThrowIfNull(serverControl);
160 this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
161 this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
162 activeChatBots = initialChatBots?.ToList() ?? throw new ArgumentNullException(nameof(initialChatBots));
163
164 restartRegistration = serverControl.RegisterForRestart(this);
165
166 synchronizationLock = new object();
167
168 builtinCommands = new Dictionary<string, ICommand>(StringComparer.OrdinalIgnoreCase);
169 providers = new Dictionary<long, IProvider>();
170 changeChannelSemaphores = new ConcurrentDictionary<long, SemaphoreSlim>();
171 mappedChannels = new Dictionary<ulong, ChannelMapping>();
172 trackingContexts = new List<IChatTrackingContext>();
173 handlerCts = new CancellationTokenSource();
174 connectionsUpdated = new TaskCompletionSource();
175
176 messageSendTask = Task.CompletedTask;
178 }
179
181 public async ValueTask DisposeAsync()
182 {
183 logger.LogTrace("Disposing...");
184 restartRegistration.Dispose();
185 handlerCts.Dispose();
186 foreach (var providerKvp in providers)
187 await providerKvp.Value.DisposeAsync();
188
189 foreach (var providerKvp in changeChannelSemaphores)
190 providerKvp.Value.Dispose();
191
192 await messageSendTask;
193 }
194
196 public async ValueTask ChangeChannels(long connectionId, IEnumerable<Models.ChatChannel> newChannels, CancellationToken cancellationToken)
197 {
198 ArgumentNullException.ThrowIfNull(newChannels);
199
200 logger.LogTrace("ChangeChannels {connectionId}...", connectionId);
201 var semaphore = changeChannelSemaphores.GetOrAdd(connectionId, _ =>
202 {
203 logger.LogTrace("Creating ChangeChannels semaphore for connection ID {connectionId}...", connectionId);
204 return new SemaphoreSlim(1);
205 });
206 using (await SemaphoreSlimContext.Lock(semaphore, cancellationToken))
207 {
208 var provider = await RemoveProviderChannels(connectionId, false, cancellationToken);
209 if (provider == null)
210 return;
211
212 if (!provider.Connected)
213 {
214 logger.LogDebug("Cannot map channels, provider {providerId} disconnected!", connectionId);
215 return;
216 }
217
218 var results = await provider.MapChannels(newChannels, cancellationToken);
219 try
220 {
221 lock (activeChatBots)
222 {
223 var botToUpdate = activeChatBots.FirstOrDefault(bot => bot.Id == connectionId);
224 if (botToUpdate != null)
225 botToUpdate.Channels = newChannels
226 .Select(apiModel => new Models.ChatChannel
227 {
228 DiscordChannelId = apiModel.DiscordChannelId,
229 IrcChannel = apiModel.IrcChannel,
230 IsAdminChannel = apiModel.IsAdminChannel,
231 IsUpdatesChannel = apiModel.IsUpdatesChannel,
232 IsSystemChannel = apiModel.IsSystemChannel,
233 IsWatchdogChannel = apiModel.IsWatchdogChannel,
234 Tag = apiModel.Tag,
235 })
236 .ToList();
237 }
238
239 var newMappings = results.SelectMany(
240 kvp => kvp.Value.Select(
241 channelRepresentation => new ChannelMapping(channelRepresentation)
242 {
243 IsWatchdogChannel = kvp.Key.IsWatchdogChannel == true,
244 IsUpdatesChannel = kvp.Key.IsUpdatesChannel == true,
245 IsAdminChannel = kvp.Key.IsAdminChannel == true,
246 IsSystemChannel = kvp.Key.IsSystemChannel == true,
247 ProviderChannelId = channelRepresentation.RealId,
248 ProviderId = connectionId,
249 }));
250
251 ulong baseId;
253 {
254 baseId = channelIdCounter;
255 channelIdCounter += (ulong)results.Count;
256 }
257
258 lock (mappedChannels)
259 {
260 lock (providers)
261 if (!providers.TryGetValue(connectionId, out var verify) || verify != provider) // aborted again
262 return;
263 foreach (var newMapping in newMappings)
264 {
265 var newId = baseId++;
266 logger.LogTrace("Mapping channel {connectionName}:{channelFriendlyName} as {newId}", newMapping.Channel.ConnectionName, newMapping.Channel.FriendlyName, newId);
267 mappedChannels.Add(newId, newMapping);
268 newMapping.Channel.RealId = newId;
269 }
270 }
271
272 // we only want to update contexts if everything at startup has connected once already
273 // otherwise we could send an incomplete channel set to the DMAPI, which will then spout all its queued messages into it instead of all relevant chatbots
274 // The watchdog can call this if it needs to after starting up
275 if (initialProviderConnectionsTask!.IsCompleted)
276 await UpdateTrackingContexts(cancellationToken);
277 }
278 finally
279 {
280 provider.InitialMappingComplete();
281 }
282 }
283 }
284
286 public async ValueTask ChangeSettings(Models.ChatBot newSettings, CancellationToken cancellationToken)
287 {
288 ArgumentNullException.ThrowIfNull(newSettings);
289
290 logger.LogTrace("ChangeSettings...");
291
292 Task disconnectTask;
293 IProvider? provider = null;
294 var newSettingsId = Models.ModelExtensions.Require(newSettings, x => x.Id);
295 var newSettingsEnabled = Models.ModelExtensions.Require(newSettings, x => x.Enabled);
296 lock (providers)
297 {
298 // raw settings changes forces a rebuild of the provider
299 if (providers.ContainsKey(newSettingsId))
300 disconnectTask = DeleteConnection(newSettingsId, cancellationToken);
301 else
302 disconnectTask = Task.CompletedTask;
303 if (newSettingsEnabled)
304 {
305 provider = providerFactory.CreateProvider(newSettings, GetCommandNames);
306 providers.Add(newSettingsId, provider);
307 }
308 }
309
310 lock (mappedChannels)
311 foreach (var oldMappedChannelId in mappedChannels.Where(x => x.Value.ProviderId == newSettingsId).Select(x => x.Key).ToList())
312 mappedChannels.Remove(oldMappedChannelId);
313
314 await disconnectTask;
315
317 {
318 // same thread shennanigans
319 var oldOne = connectionsUpdated;
320 connectionsUpdated = new TaskCompletionSource();
321 oldOne.SetResult();
322 }
323
324 var reconnectionUpdateTask = provider?.SetReconnectInterval(
325 Models.ModelExtensions.Require(newSettings, x => x.ReconnectionInterval),
326 newSettingsEnabled)
327 ?? Task.CompletedTask;
328 lock (activeChatBots)
329 {
330 var originalChatBot = activeChatBots.FirstOrDefault(bot => bot.Id == newSettings.Id);
331 if (originalChatBot != null)
332 activeChatBots.Remove(originalChatBot);
333
334 activeChatBots.Add(new Models.ChatBot(newSettings.Channels)
335 {
336 Id = newSettings.Id,
337 ConnectionString = newSettings.ConnectionString,
338 Enabled = newSettings.Enabled,
339 Name = newSettings.Name,
340 ReconnectionInterval = newSettings.ReconnectionInterval,
341 Provider = newSettings.Provider,
342 });
343 }
344
345 await reconnectionUpdateTask;
346 }
347
349 public void QueueMessage(MessageContent message, IEnumerable<ulong> channelIds)
350 {
351 ArgumentNullException.ThrowIfNull(message);
352 ArgumentNullException.ThrowIfNull(channelIds);
353
354 QueueMessageInternal(message, () => channelIds, false);
355 }
356
358 public void QueueWatchdogMessage(string message)
359 => QueueMessageGeneric(mapping => mapping.IsWatchdogChannel, message, "WD");
360
362 public void QueueRawDeploymentMessage(string message)
363 => QueueMessageGeneric(mapping => mapping.IsUpdatesChannel, message, null);
364
366 public Func<string?, string, Action<bool>> QueueDeploymentMessage(
367 Models.RevisionInformation revisionInformation,
368 Models.RevisionInformation? previousRevisionInformation,
369 EngineVersion engineVersion,
370 DateTimeOffset? estimatedCompletionTime,
371 string? gitHubOwner,
372 string? gitHubRepo,
373 bool localCommitPushed)
374 {
375 List<ulong> wdChannels;
376 lock (mappedChannels) // so it doesn't change while we're using it
377 wdChannels = mappedChannels.Where(x => x.Value.IsUpdatesChannel).Select(x => x.Key).ToList();
378
379 logger.LogTrace("Sending deployment message for RevisionInformation: {revisionInfoId}", revisionInformation.Id);
380
381 var callbacks = new List<Func<string?, string, ValueTask<Func<bool, ValueTask>>>>();
382
383 var task = Task.WhenAll(
384 wdChannels.Select(
385 async x =>
386 {
387 ChannelMapping? channelMapping;
388 lock (mappedChannels)
389 if (!mappedChannels.TryGetValue(x, out channelMapping))
390 return;
391 IProvider? provider;
392 lock (providers)
393 if (!providers.TryGetValue(channelMapping.ProviderId, out provider))
394 return;
395 try
396 {
397 var callback = await provider.SendUpdateMessage(
398 revisionInformation,
399 previousRevisionInformation,
400 engineVersion,
401 estimatedCompletionTime,
402 gitHubOwner,
403 gitHubRepo,
404 channelMapping.ProviderChannelId,
405 localCommitPushed,
406 handlerCts.Token);
407
408 lock (callbacks)
409 callbacks.Add(callback);
410 }
411 catch (Exception ex)
412 {
413 logger.LogWarning(
414 ex,
415 "Error sending deploy message to provider {providerId}!",
416 channelMapping.ProviderId);
417 }
418 }));
419
420 AddMessageTask(task);
421
422 Task callbackTask;
423 Func<bool, Task>? finalUpdateAction = null;
424 async Task CallbackTask(string? errorMessage, string dreamMakerOutput)
425 {
426 await task;
427 var callbackResults = await ValueTaskExtensions.WhenAll(
428 callbacks.Select(
429 x => x(
430 errorMessage,
431 dreamMakerOutput)),
432 callbacks.Count);
433
434 finalUpdateAction = active => ValueTaskExtensions.WhenAll(callbackResults.Select(finalizerCallback => finalizerCallback(active))).AsTask();
435 }
436
437 async Task CompletionTask(bool active)
438 {
439 try
440 {
441 await callbackTask;
442 }
443 catch
444 {
445 // Handled in AddMessageTask
446 return;
447 }
448
449 AddMessageTask(finalUpdateAction!(active));
450 }
451
452 return (errorMessage, dreamMakerOutput) =>
453 {
454 callbackTask = CallbackTask(errorMessage, dreamMakerOutput);
455 AddMessageTask(callbackTask);
456 return active => AddMessageTask(CompletionTask(active));
457 };
458 }
459
461 public async Task StartAsync(CancellationToken cancellationToken)
462 {
463 foreach (var tgsCommand in commandFactory.GenerateCommands())
464 builtinCommands.Add(tgsCommand.Name.ToUpperInvariant(), tgsCommand);
465 var initialChatBots = activeChatBots.ToList();
466 await ValueTaskExtensions.WhenAll(initialChatBots.Select(x => ChangeSettings(x, cancellationToken)));
467 initialProviderConnectionsTask = InitialConnection();
468 chatHandler = MonitorMessages(handlerCts.Token);
469 }
470
472 public async Task StopAsync(CancellationToken cancellationToken)
473 {
474 handlerCts.Cancel();
475 if (chatHandler != null)
476 await chatHandler;
477 await Task.WhenAll(providers.Select(x => x.Key).Select(x => DeleteConnection(x, cancellationToken)));
478 await messageSendTask;
479 }
480
483 {
484 if (customCommandHandler == null)
485 throw new InvalidOperationException("RegisterCommandHandler() hasn't been called!");
486
487 IChatTrackingContext context = null!;
488 lock (mappedChannels)
489 context = new ChatTrackingContext(
490 customCommandHandler,
491 mappedChannels.Select(y => y.Value.Channel),
492 loggerFactory.CreateLogger<ChatTrackingContext>(),
493 () =>
494 {
495 lock (trackingContexts)
496 trackingContexts.Remove(context);
497 });
498
499 lock (trackingContexts)
500 trackingContexts.Add(context);
501
502 return context;
503 }
504
506 public async ValueTask UpdateTrackingContexts(CancellationToken cancellationToken)
507 {
508 var logMessageSent = 0;
509 async Task UpdateTrackingContext(IChatTrackingContext channelSink, IEnumerable<ChannelRepresentation> channels)
510 {
511 if (Interlocked.Exchange(ref logMessageSent, 1) == 0)
512
513 await channelSink.UpdateChannels(channels, cancellationToken);
514 }
515
516 var waitingForInitialConnection = !initialProviderConnectionsTask!.IsCompleted;
517 if (waitingForInitialConnection)
518 {
519 logger.LogTrace("Waiting for initial chat bot connections before updating tracking contexts...");
520 await initialProviderConnectionsTask.WaitAsync(cancellationToken);
521 }
522
523 List<Task> tasks;
524 lock (mappedChannels)
525 lock (trackingContexts)
526 tasks = trackingContexts.Select(x => UpdateTrackingContext(x, mappedChannels.Select(y => y.Value.Channel))).ToList();
527
528 if (waitingForInitialConnection)
529 if (tasks.Count > 0)
530 logger.LogTrace("Updating chat tracking contexts...");
531 else
532 logger.LogTrace("No chat tracking contexts to update");
533
534 await Task.WhenAll(tasks);
535 }
536
538 public void RegisterCommandHandler(ICustomCommandHandler customCommandHandler)
539 {
540 if (this.customCommandHandler != null)
541 throw new InvalidOperationException("RegisterCommandHandler() already called!");
542 this.customCommandHandler = customCommandHandler ?? throw new ArgumentNullException(nameof(customCommandHandler));
543 }
544
546 public async Task DeleteConnection(long connectionId, CancellationToken cancellationToken)
547 {
548 logger.LogTrace("DeleteConnection {connectionId}", connectionId);
549 var hasSemaphore = changeChannelSemaphores.TryRemove(connectionId, out var semaphore);
550 using (hasSemaphore
551 ? semaphore
552 : null)
553 using (hasSemaphore
554 ? await SemaphoreSlimContext.Lock(semaphore!, cancellationToken)
555 : null)
556 {
557 var provider = await RemoveProviderChannels(connectionId, true, cancellationToken);
558 if (provider != null)
559 {
560 var startTime = DateTimeOffset.UtcNow;
561 try
562 {
563 await provider.Disconnect(cancellationToken);
564 }
565 catch (Exception ex)
566 {
567 logger.LogError(ex, "Error disconnecting connection {connectionId}!", connectionId);
568 }
569
570 await provider.DisposeAsync();
571 var duration = DateTimeOffset.UtcNow - startTime;
572 if (duration.TotalSeconds > 3)
573 logger.LogWarning("Disconnecting a {providerType} took {totalSeconds}s!", provider.GetType().Name, duration.TotalSeconds);
574 }
575 else
576 logger.LogTrace("DeleteConnection: ID {connectionId} doesn't exist!", connectionId);
577 }
578 }
579
581 public ValueTask HandleRestart(Version? updateVersion, bool handlerMayDelayShutdownWithExtremelyLongRunningTasks, CancellationToken cancellationToken)
582 {
583 var message = updateVersion == null
584 ? $"TGS: {(handlerMayDelayShutdownWithExtremelyLongRunningTasks ? "Graceful shutdown" : "Going down")}..."
585 : $"TGS: Updating to version {updateVersion}...";
586 List<ulong> systemChannels;
587 lock (mappedChannels) // so it doesn't change while we're using it
588 systemChannels = mappedChannels
589 .Where(x => x.Value.IsSystemChannel)
590 .Select(x => x.Key)
591 .ToList();
592
593 return SendMessage(
594 systemChannels,
595 null,
597 {
598 Text = message,
599 },
600 cancellationToken);
601 }
602
607 IReadOnlyList<string> GetCommandNames()
608 {
609 var commands = new List<string> { HelpCommandName };
610 commands.AddRange(builtinCommands.Values.Select(command => command.Name));
611 lock (trackingContexts)
612 commands.AddRange(
613 trackingContexts
614 .Where(trackingContext => trackingContext.Active)
615 .SelectMany(trackingContext => trackingContext.CustomCommands.Select(command => command.Name)));
616
617 return commands
618 .Distinct(StringComparer.OrdinalIgnoreCase)
619 .OrderBy(command => command, StringComparer.OrdinalIgnoreCase)
620 .ToList();
621 }
622
630 async ValueTask<IProvider?> RemoveProviderChannels(long connectionId, bool removeProvider, CancellationToken cancellationToken)
631 {
632 logger.LogTrace("RemoveProviderChannels {connectionId}...", connectionId);
633 IProvider? provider;
634 lock (providers)
635 {
636 if (!providers.TryGetValue(connectionId, out provider))
637 {
638 logger.LogTrace("Aborted, no such provider!");
639 return null;
640 }
641
642 if (removeProvider)
643 providers.Remove(connectionId);
644 }
645
646 ValueTask trackingContextsUpdateTask;
647 lock (mappedChannels)
648 {
649 foreach (var mappedConnectionChannel in mappedChannels.Where(x => x.Value.ProviderId == connectionId).Select(x => x.Key).ToList())
650 mappedChannels.Remove(mappedConnectionChannel);
651
652 var newMappedChannels = mappedChannels.Select(y => y.Value.Channel).ToList();
653
654 if (removeProvider)
655 lock (trackingContexts)
656 trackingContextsUpdateTask = ValueTaskExtensions.WhenAll(trackingContexts.Select(x => x.UpdateChannels(newMappedChannels, cancellationToken)));
657 else
658 trackingContextsUpdateTask = ValueTask.CompletedTask;
659 }
660
661 await trackingContextsUpdateTask;
662
663 return provider;
664 }
665
672 async ValueTask RemapProvider(IProvider provider, CancellationToken cancellationToken)
673 {
674 logger.LogTrace("Remapping channels for provider reconnection...");
675 IEnumerable<Models.ChatChannel>? channelsToMap;
676 long providerId;
677 lock (providers)
678 providerId = providers.Where(x => x.Value == provider).Select(x => x.Key).First();
679
680 lock (activeChatBots)
681 channelsToMap = activeChatBots.FirstOrDefault(x => x.Id == providerId)?.Channels;
682
683 if (channelsToMap?.Any() ?? false)
684 await ChangeChannels(providerId, channelsToMap, cancellationToken);
685 }
686
695#pragma warning disable CA1502
696 async ValueTask ProcessMessage(IProvider provider, Message? message, bool recursed, CancellationToken cancellationToken)
697#pragma warning restore CA1502
698 {
699 if (!provider.Connected)
700 {
701 logger.LogTrace("Abort message processing because provider is disconnected!");
702 return;
703 }
704
705 // provider reconnected, remap channels.
706 if (message == null)
707 {
708 await RemapProvider(provider, cancellationToken);
709 return;
710 }
711
712 // map the channel if it's private and we haven't seen it
713 var providerChannelId = message.User.Channel.RealId;
714 KeyValuePair<ulong, ChannelMapping>? mappedChannel;
715 long providerId;
716 bool hasChannelZero;
717 lock (providers)
718 {
719 // important, otherwise we could end up processing during shutdown
720 cancellationToken.ThrowIfCancellationRequested();
721
722 var providerIdNullable = providers
723 .Where(x => x.Value == provider)
724 .Select(x => (long?)x.Key)
725 .FirstOrDefault();
726
727 if (!providerIdNullable.HasValue)
728 {
729 // possible to have a message queued and then the provider immediately disconnects
730 logger.LogDebug("Unable to process command \"{command}\" due to provider disconnecting", message.Content);
731 return;
732 }
733
734 providerId = providerIdNullable.Value;
735 mappedChannel = mappedChannels
736 .Where(x => x.Value.ProviderId == providerId && x.Value.ProviderChannelId == providerChannelId)
737 .Select(x => (KeyValuePair<ulong, ChannelMapping>?)x)
738 .FirstOrDefault();
739 hasChannelZero = mappedChannels
740 .Where(x => x.Value.ProviderId == providerId && x.Value.ProviderChannelId == 0)
741 .Any();
742 }
743
744 if (!recursed && !mappedChannel.HasValue && !message.User.Channel.IsPrivateChannel && hasChannelZero)
745 {
746 logger.LogInformation("Receieved message from unmapped channel whose provider contains ID 0. Remapping...");
747 await RemapProvider(provider, cancellationToken);
748 logger.LogTrace("Resume processing original message...");
749 await ProcessMessage(provider, message, true, cancellationToken);
750 return;
751 }
752
753 ValueTask TextReply(string reply) => SendMessage(
754 new List<ulong>
755 {
756 message.User.Channel.RealId,
757 },
758 message,
760 {
761 Text = reply,
762 },
763 cancellationToken);
764
765 if (message.User.Channel.IsPrivateChannel)
766 lock (mappedChannels)
767 if (!mappedChannel.HasValue)
768 {
769 ulong newId;
770 lock (synchronizationLock)
771 newId = channelIdCounter++;
772 logger.LogTrace(
773 "Mapping private channel {connectionName}:{channelFriendlyName} as {newId}",
775 message.User.FriendlyName,
776 newId);
777 mappedChannels.Add(newId, new ChannelMapping(message.User.Channel)
778 {
779 ProviderChannelId = message.User.Channel.RealId,
780 ProviderId = providerId,
781 });
782
783 logger.LogTrace(
784 "Mapping DM {connectionName}:{userId} ({userFriendlyName}) as {newId}",
786 message.User.RealId,
787 message.User.FriendlyName,
788 newId);
789 message.User.Channel.RealId = newId;
790 }
791 else
792 message.User.Channel.RealId = mappedChannel.Value.Key;
793 else
794 {
795 if (!mappedChannel.HasValue)
796 {
797 logger.LogError(
798 "Error mapping message: Provider ID: {providerId}, Channel Real ID: {realId}",
799 providerId,
800 message.User.Channel.RealId);
801 logger.LogTrace("message: {messageJson}", JsonConvert.SerializeObject(message));
802 lock (mappedChannels)
803 logger.LogTrace("mappedChannels: {mappedChannelsJson}", JsonConvert.SerializeObject(mappedChannels));
804 await TextReply("TGS: Processing error, check logs!");
805 return;
806 }
807
808 var mappingChannelRepresentation = mappedChannel.Value.Value.Channel;
809
810 message.User.Channel.RealId = mappingChannelRepresentation.RealId;
811 message.User.Channel.Tag = mappingChannelRepresentation.Tag;
812 message.User.Channel.IsAdminChannel = mappingChannelRepresentation.IsAdminChannel;
813 }
814
815 var trimmedMessage = message.Content.Trim();
816 if (trimmedMessage.Length == 0)
817 return;
818
819 var splits = new List<string>(trimmedMessage.Split(' ', StringSplitOptions.RemoveEmptyEntries));
820 var address = splits[0];
821 if (address.Length > 1 && (address.Last() == ':' || address.Last() == ','))
822 address = address[0..^1];
823
824 var addressed =
825 address.Equals(CommonMention, StringComparison.OrdinalIgnoreCase)
826 || address.Equals(provider.BotMention, StringComparison.OrdinalIgnoreCase);
827
828 // no mention
829 if (!addressed && !message.User.Channel.IsPrivateChannel)
830 return;
831
832 logger.LogTrace(
833 "Start processing command: {message}. User (True provider Id): {profiderId}",
834 message.Content,
835 JsonConvert.SerializeObject(message.User));
836 try
837 {
838 if (addressed)
839 splits.RemoveAt(0);
840
841 if (splits.Count == 0)
842 {
843 // just a mention
844 await TextReply("Hi!");
845 return;
846 }
847
848 var command = splits[0];
849 splits.RemoveAt(0);
850 var arguments = String.Join(" ", splits);
851
852 Tuple<ICommand, IChatTrackingContext?>? GetCommand(string command)
853 {
854 if (!builtinCommands.TryGetValue(command, out var handler))
855 return trackingContexts
856 .Where(trackingContext => trackingContext.Active)
857 .SelectMany(trackingContext => trackingContext.CustomCommands.Select(customCommand => Tuple.Create<ICommand, IChatTrackingContext?>(customCommand, trackingContext)))
858 .Where(tuple => tuple.Item1.Name.Equals(command, StringComparison.OrdinalIgnoreCase))
859 .FirstOrDefault();
860
861 return Tuple.Create<ICommand, IChatTrackingContext?>(handler, null);
862 }
863
864 const string UnknownCommandMessage = "TGS: Unknown command! Type '?' or 'help' for available commands.";
865
866 if (command.Equals(HelpCommandName, StringComparison.OrdinalIgnoreCase) || command == "?")
867 {
868 string helpText;
869 if (splits.Count == 0)
870 {
871 var allCommands = builtinCommands.Select(x => x.Value).ToList();
872 allCommands.AddRange(
873 trackingContexts
874 .SelectMany(
875 x => x.CustomCommands));
876 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)));
877 }
878 else
879 {
880 var helpTuple = GetCommand(splits[0]);
881 if (helpTuple != default)
882 {
883 var (helpHandler, _) = helpTuple;
884 helpText = String.Format(CultureInfo.InvariantCulture, "{0}: {1}{2}", helpHandler.Name, helpHandler.HelpText, helpHandler.AdminOnly ? " - May only be used in admin channels" : String.Empty);
885 }
886 else
887 helpText = UnknownCommandMessage;
888 }
889
890 await TextReply(helpText);
891 return;
892 }
893
894 var tuple = GetCommand(command);
895
896 if (tuple == default)
897 {
898 await TextReply(UnknownCommandMessage);
899 return;
900 }
901
902 var (commandHandler, trackingContext) = tuple;
903
904 if (trackingContext?.Active == false)
905 {
906 await TextReply("TGS: The server is rebooting, please try again later");
907 return;
908 }
909
910 if (commandHandler.AdminOnly && !message.User.Channel.IsAdminChannel)
911 {
912 await TextReply("TGS: Use this command in an admin channel!");
913 return;
914 }
915
916 var result = await commandHandler.Invoke(arguments, message.User, cancellationToken);
917 if (result != null)
918 await SendMessage(new List<ulong> { message.User.Channel.RealId }, message, result, cancellationToken);
919 }
920 catch (OperationCanceledException ex)
921 {
922 logger.LogTrace(ex, "Command processing canceled!");
923 }
924 catch (Exception e)
925 {
926 // error bc custom commands should reply about why it failed
927 logger.LogError(e, "Error processing chat command");
928 await TextReply("TGS: Internal error processing command! Check server logs!");
929 }
930 finally
931 {
932 logger.LogTrace("Done processing command.");
933 }
934 }
935
941 async Task MonitorMessages(CancellationToken cancellationToken)
942 {
943 logger.LogTrace("Starting processing loop...");
944 var messageTasks = new Dictionary<IProvider, Task<Message?>>();
945 ValueTask activeProcessingTask = ValueTask.CompletedTask;
946 try
947 {
948 Task? updatedTask = null;
949 while (!cancellationToken.IsCancellationRequested)
950 {
951 if (updatedTask?.IsCompleted != false)
952 lock (synchronizationLock)
953 updatedTask = connectionsUpdated.Task;
954
955 // prune disconnected providers
956 foreach (var disposedProviderMessageTaskKvp in messageTasks.Where(x => x.Key.Disposed).ToList())
957 messageTasks.Remove(disposedProviderMessageTaskKvp.Key);
958
959 // add new ones
960 lock (providers)
961 foreach (var providerKvp in providers)
962 if (!messageTasks.ContainsKey(providerKvp.Value))
963 messageTasks.Add(
964 providerKvp.Value,
965 providerKvp.Value.NextMessage(cancellationToken));
966
967 if (messageTasks.Count == 0)
968 {
969 logger.LogTrace("No providers active, pausing messsage monitoring...");
970 await updatedTask.WaitAsync(cancellationToken);
971 logger.LogTrace("Resuming message monitoring...");
972 continue;
973 }
974
975 // wait for a message
976 await Task.WhenAny(updatedTask, Task.WhenAny(messageTasks.Select(x => x.Value)));
977
978 // process completed ones
979 foreach (var completedMessageTaskKvp in messageTasks.Where(x => x.Value.IsCompleted).ToList())
980 {
981 var provider = completedMessageTaskKvp.Key;
982 messageTasks.Remove(provider);
983
984 if (provider.Disposed) // valid to receive one, but don't process it
985 continue;
986
987 var message = await completedMessageTaskKvp.Value;
988 var messageNumber = Interlocked.Increment(ref messagesProcessed);
989
990 async ValueTask WrapProcessMessage()
991 {
992 var localActiveProcessingTask = activeProcessingTask;
993 using (LogContext.PushProperty(SerilogContextHelper.ChatMessageIterationContextProperty, messageNumber))
994 try
995 {
996 await ProcessMessage(provider, message, false, cancellationToken);
997 }
998 catch (Exception ex)
999 {
1000 logger.LogError(ex, "Error processing message {messageNumber}!", messageNumber);
1001 }
1002
1003 await localActiveProcessingTask;
1004 }
1005
1006 activeProcessingTask = WrapProcessMessage();
1007 }
1008 }
1009 }
1010 catch (OperationCanceledException ex)
1011 {
1012 logger.LogTrace(ex, "Message processing loop cancelled!");
1013 }
1014 catch (Exception e)
1015 {
1016 logger.LogError(e, "Message loop crashed!");
1017 }
1018 finally
1019 {
1020 await activeProcessingTask;
1021 }
1022
1023 logger.LogTrace("Leaving message processing loop");
1024 }
1025
1034 ValueTask SendMessage(IEnumerable<ulong> channelIds, Message? replyTo, MessageContent message, CancellationToken cancellationToken)
1035 {
1036 var channelIdsList = channelIds.ToList();
1037
1038 logger.LogTrace(
1039 "Chat send \"{message}\"{embed} to channels: [{channelIdsCommaSeperated}]",
1040 message.Text,
1041 message.Embed != null ? " (with embed)" : String.Empty,
1042 String.Join(", ", channelIdsList));
1043
1044 if (channelIdsList.Count == 0)
1045 return ValueTask.CompletedTask;
1046
1048 channelIdsList.Select(x =>
1049 {
1050 ChannelMapping? channelMapping;
1051 lock (mappedChannels)
1052 if (!mappedChannels.TryGetValue(x, out channelMapping))
1053 return ValueTask.CompletedTask;
1054 IProvider? provider;
1055 lock (providers)
1056 if (!providers.TryGetValue(channelMapping.ProviderId, out provider))
1057 return ValueTask.CompletedTask;
1058 return provider.SendMessage(replyTo, message, channelMapping.ProviderChannelId, cancellationToken);
1059 }));
1060 }
1061
1067 {
1068 await Task.WhenAll(providers.Select(x => x.Value.InitialConnectionJob));
1069 logger.LogTrace("Initial provider connection task completed");
1070 }
1071
1076 void AddMessageTask(Task task)
1077 {
1078 async Task Wrap(Task originalTask)
1079 {
1080 await originalTask;
1081 try
1082 {
1083 await task;
1084 }
1085 catch (OperationCanceledException ex)
1086 {
1087 logger.LogDebug(ex, "Async chat message cancelled!");
1088 }
1089 catch (Exception ex)
1090 {
1091 logger.LogError(ex, "Error in asynchronous chat message!");
1092 }
1093 }
1094
1095 lock (handlerCts)
1096 messageSendTask = Wrap(messageSendTask);
1097 }
1098
1105 void QueueMessageInternal(MessageContent message, Func<IEnumerable<ulong>> channelIdsFactory, bool waitForConnections)
1106 {
1107 async Task SendMessageTask()
1108 {
1109 var cancellationToken = handlerCts.Token;
1110 if (waitForConnections)
1111 await initialProviderConnectionsTask!.WaitAsync(cancellationToken);
1112
1113 await SendMessage(
1114 channelIdsFactory(),
1115 null,
1116 message,
1117 cancellationToken);
1118 }
1119
1120 AddMessageTask(SendMessageTask());
1121 }
1122
1129 void QueueMessageGeneric(Predicate<ChannelMapping> channelSelector, string message, string? prefix)
1130 {
1131 ArgumentNullException.ThrowIfNull(message);
1132
1133 if (prefix != null)
1134 {
1135 message = $"{prefix}: {message}";
1136 }
1137
1138 if (!initialProviderConnectionsTask!.IsCompleted)
1139 logger.LogTrace("Waiting for initial provider connections before sending chat message...");
1140
1141 // Reimplementing QueueMessage
1142 QueueMessageInternal(
1143 new MessageContent
1144 {
1145 Text = message,
1146 },
1147 () =>
1148 {
1149 // so it doesn't change while we're using it
1150 lock (mappedChannels)
1151 return mappedChannels.Where(x => channelSelector(x.Value)).Select(x => x.Key).ToList();
1152 },
1153 true);
1154 }
1155 }
1156}
Information about an engine installation.
Extension methods for the ValueTask and ValueTask<TResult> classes.
static async ValueTask WhenAll(IEnumerable< ValueTask > tasks)
Fully await a given list of tasks .
Represents a mapping of a ChannelRepresentation.RealId.
string ConnectionName
The name of the connection the ChannelRepresentation belongs to.
bool IsAdminChannel
If this is considered a channel for admin commands.
const string CommonMention
The common bot mention.
long messagesProcessed
The number of Messages processed.
readonly IProviderFactory providerFactory
The IProviderFactory for the ChatManager.
ValueTask SendMessage(IEnumerable< ulong > channelIds, Message? replyTo, MessageContent message, CancellationToken cancellationToken)
Asynchronously send a given message to a set of channelIds .
IReadOnlyList< string > GetCommandNames()
Gets the currently available chat command names.
readonly object synchronizationLock
Used for various lock statements throughout this class.
ChatManager(IProviderFactory providerFactory, ICommandFactory commandFactory, IServerControl serverControl, ILoggerFactory loggerFactory, ILogger< ChatManager > logger, IEnumerable< Models.ChatBot > initialChatBots)
Initializes a new instance of the ChatManager class.
const string HelpCommandName
The name of the built-in help command.
Task? initialProviderConnectionsTask
A Task that represents the IProviders initial connection.
ValueTask HandleRestart(Version? updateVersion, bool handlerMayDelayShutdownWithExtremelyLongRunningTasks, CancellationToken cancellationToken)
Handle a restart of the server.A ValueTask representing the running operation.
void QueueMessageInternal(MessageContent message, Func< IEnumerable< ulong > > channelIdsFactory, bool waitForConnections)
Adds a given message to the send queue.
readonly List< Models.ChatBot > activeChatBots
The active Models.ChatBot for the ChatManager.
async Task InitialConnection()
Aggregate all IProvider.InitialConnectionJobs into one <sse cref="Task">.
async ValueTask RemapProvider(IProvider provider, CancellationToken cancellationToken)
Remap the channels for a given provider .
async ValueTask ProcessMessage(IProvider provider, Message? message, bool recursed, CancellationToken cancellationToken)
Processes a message .
readonly Dictionary< long, IProvider > providers
Map of IProviders in use, keyed by ChatBotSettings EntityId.Id.
async Task MonitorMessages(CancellationToken cancellationToken)
Monitors active providers for new Messages.
ICustomCommandHandler? customCommandHandler
The ICustomCommandHandler for the ChangeChannels(long, IEnumerable<Models.ChatChannel>,...
async ValueTask ChangeChannels(long connectionId, IEnumerable< Models.ChatChannel > newChannels, CancellationToken cancellationToken)
readonly Dictionary< string, ICommand > builtinCommands
Unchanging ICommands in the ChatManager mapped by ICommand.Name.
void QueueMessage(MessageContent message, IEnumerable< ulong > channelIds)
Queue a chat message to a given set of channelIds .
readonly ILoggerFactory loggerFactory
The ILoggerFactory for the ChatManager.
void QueueMessageGeneric(Predicate< ChannelMapping > channelSelector, string message, string? prefix)
Queues a message to a selected set of ChannelMappings.
Task messageSendTask
A Task that represents all sent messages.
Task? chatHandler
The Task that monitors incoming chat messages.
async ValueTask ChangeSettings(Models.ChatBot newSettings, CancellationToken cancellationToken)
Change chat settings. If the Api.Models.EntityId.Id is not currently in use, a new connection will be...
ulong channelIdCounter
Used for remapping ChannelRepresentation.RealIds.
readonly ICommandFactory commandFactory
The ICommandFactory for the ChatManager.
IChatTrackingContext CreateTrackingContext()
Start tracking Commands.CustomCommands and ChannelRepresentations.A new IChatTrackingContext.
void AddMessageTask(Task task)
Adds a given task to messageSendTask.
readonly ILogger< ChatManager > logger
The ILogger for the ChatManager.
readonly ConcurrentDictionary< long, SemaphoreSlim > changeChannelSemaphores
Map of SemaphoreSlims used to guard concurrent access to ChangeChannels(long, IEnumerable<Models....
void QueueRawDeploymentMessage(string message)
Queue a chat message to configured deployment channels.
readonly IRestartRegistration restartRegistration
The IRestartRegistration for the ChatManager.
void QueueWatchdogMessage(string message)
Queue a chat message to configured watchdog channels.
readonly CancellationTokenSource handlerCts
The CancellationTokenSource for chatHandler.
async Task StartAsync(CancellationToken cancellationToken)
void RegisterCommandHandler(ICustomCommandHandler customCommandHandler)
Registers a customCommandHandler to use.
TaskCompletionSource connectionsUpdated
The TaskCompletionSource that completes when ChatBotSettingss change.
async ValueTask UpdateTrackingContexts(CancellationToken cancellationToken)
Force an update with the active channels on all active IChatTrackingContexts.A ValueTask representing...
async Task DeleteConnection(long connectionId, CancellationToken cancellationToken)
Disconnects and deletes a given connection.A Task representing the running operation.
Func< string?, string, Action< bool > > QueueDeploymentMessage(Models.RevisionInformation revisionInformation, Models.RevisionInformation? previousRevisionInformation, EngineVersion engineVersion, DateTimeOffset? estimatedCompletionTime, string? gitHubOwner, string? gitHubRepo, bool localCommitPushed)
Send the message for a deployment to configured deployment channels.A Func<T1, T2,...
async ValueTask< IProvider?> RemoveProviderChannels(long connectionId, bool removeProvider, CancellationToken cancellationToken)
Remove a IProvider from mappedChannels optionally removing the provider itself from providers and upd...
async Task StopAsync(CancellationToken cancellationToken)
readonly List< IChatTrackingContext > trackingContexts
The active IChatTrackingContexts for the ChatManager.
readonly Dictionary< ulong, ChannelMapping > mappedChannels
Map of ChannelRepresentation.RealIds to ChannelMappings.
string FriendlyName
The friendly name of the user.
Definition ChatUser.cs:31
ChannelRepresentation Channel
The ChannelRepresentation the user spoke from.
Definition ChatUser.cs:41
Represents a message received by a IProvider.
Definition Message.cs:9
ChatUser User
The ChatUser who sent the Message.
Definition Message.cs:18
Represents a message to send to a chat provider.
static async ValueTask< SemaphoreSlimContext > Lock(SemaphoreSlim semaphore, CancellationToken cancellationToken, ILogger? logger=null)
Asyncronously locks a semaphore .
Helpers for manipulating the Serilog.Context.LogContext.
const string ChatMessageIterationContextProperty
The Serilog.Context.LogContext property name for the ID of the chat message currently being processed...
Represents a command that can be invoked by talking to chat bots.
Definition ICommand.cs:12
ValueTask UpdateChannels(IEnumerable< ChannelRepresentation > newChannels, CancellationToken cancellationToken)
Called when newChannels are set.
For managing connected chat services.
Represents a tracking of dynamic chat json files.
Handles Commands.ICommands that map to those defined in a IChatTrackingContext.
IProvider CreateProvider(ChatBot settings, Func< IEnumerable< string > > commandNamesFactory)
Create a IProvider.
string BotMention
The string that indicates the IProvider was mentioned.
Definition IProvider.cs:30
bool Connected
If the IProvider is currently connected.
Definition IProvider.cs:20
Represents the lifetime of a IRestartHandler registration.
Represents a service that may take an updated Host assembly and run it, stopping the current assembly...
IRestartRegistration RegisterForRestart(IRestartHandler handler)
Register a given handler to run before stopping the server for a restart.