tgstation-server 6.19.3
The /tg/station 13 server suite
Loading...
Searching...
No Matches
DiscordProvider.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Concurrent;
3using System.Collections.Generic;
4using System.Drawing;
5using System.Globalization;
6using System.Linq;
7using System.Threading;
8using System.Threading.Tasks;
9
10using Microsoft.Extensions.DependencyInjection;
11using Microsoft.Extensions.Logging;
12using Microsoft.Extensions.Options;
13
14using OneOf;
15
16using Remora.Discord.API.Abstractions.Gateway.Commands;
17using Remora.Discord.API.Abstractions.Gateway.Events;
18using Remora.Discord.API.Abstractions.Objects;
19using Remora.Discord.API.Abstractions.Rest;
20using Remora.Discord.API.Abstractions.Results;
21using Remora.Discord.API.Objects;
22using Remora.Discord.Gateway;
23using Remora.Discord.Gateway.Extensions;
24using Remora.Rest.Core;
25using Remora.Rest.Results;
26using Remora.Results;
27
38
40{
44 #pragma warning disable CA1506
46 {
48 public override bool Connected => gatewayTask?.IsCompleted == false;
49
51 public override string BotMention
52 {
53 get
54 {
55 if (!Connected)
56 throw new InvalidOperationException("Provider not connected");
57 return NormalizeMentions($"<@{currentUserId}>");
58 }
59 }
60
64 const string SlashCommandDescription = "Run a TGS chat command.";
65
69 const string SlashCommandOptionName = "command";
70
74 const string SlashCommandArgumentsOptionName = "arguments";
75
79 const int MaxAutocompleteChoices = 25;
80
86 public static string NormalizeSlashCommandName(string? commandName)
87 {
88 var normalized = commandName?.Trim();
89 if (String.IsNullOrWhiteSpace(normalized))
91
92#pragma warning disable CA1308 // Discord slash command names must be lowercase.
93 normalized = normalized.ToLowerInvariant();
94#pragma warning restore CA1308
95
96 if (normalized.Length > 32 || normalized.Any(character => !Char.IsLetterOrDigit(character) && character is not '-' and not '_' and not '\''))
97 throw new ArgumentException("DiscordSlashCommandName must be 1-32 letters, numbers, '-', '_' or apostrophes without a leading '/'.", nameof(commandName));
98
99 return normalized;
100 }
101
108 public static bool IsTgsSlashCommand(IApplicationCommand command, string configuredCommandName)
109 {
110 ArgumentNullException.ThrowIfNull(command);
111 ArgumentNullException.ThrowIfNull(configuredCommandName);
112
113 return command.Type == ApplicationCommandType.ChatInput
114 && !command.Name.Equals(configuredCommandName, StringComparison.OrdinalIgnoreCase)
115 && String.Equals(command.Description, SlashCommandDescription, StringComparison.Ordinal);
116 }
117
124 static string BuildSlashCommandMessageContent(string commandName, string? arguments = null)
125 {
126 var trimmedArguments = arguments?.Trim();
127 return String.IsNullOrWhiteSpace(trimmedArguments)
128 ? $"{ChatManager.CommonMention} {commandName.Trim()}"
129 : $"{ChatManager.CommonMention} {commandName.Trim()} {trimmedArguments}";
130 }
131
137 static bool HasMessageContentIntent(ApplicationFlags applicationFlags)
138 => applicationFlags.HasFlag(ApplicationFlags.GatewayMessageContent)
139 || applicationFlags.HasFlag(ApplicationFlags.GatewayMessageContentLimited);
140
147 static string? GetStringOption(IApplicationCommandData commandData, string optionName)
148 {
149 var option = commandData.Options.HasValue
150 ? commandData.Options.Value.FirstOrDefault(option => option.Name.Equals(optionName, StringComparison.Ordinal))
151 : null;
152
153 if (option == null || !option.Value.HasValue || !option.Value.Value.IsT0)
154 return null;
155
156 return option.Value.Value.AsT0;
157 }
158
162 static readonly ChannelType[] SupportedGuildChannelTypes =
163 [
164 ChannelType.GuildText,
165 ChannelType.GuildAnnouncement,
166 ChannelType.PrivateThread,
167 ChannelType.PublicThread,
168 ];
169
173 static readonly IReadOnlyList<IApplicationCommandOption> SlashCommandOptions =
174 [
175 new ApplicationCommandOption(
176 ApplicationCommandOptionType.String,
178 "The TGS command name.",
179 IsRequired: true,
180 EnableAutocomplete: true),
181 new ApplicationCommandOption(
182 ApplicationCommandOptionType.String,
184 "The TGS command arguments."),
185 ];
186
191
195 readonly IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions;
196
200 readonly ServiceProvider serviceProvider;
201
205 readonly Func<IEnumerable<string>> commandNamesFactory;
206
210 readonly List<ulong> mappedChannels;
211
215 readonly object connectDisconnectLock;
216
220 readonly bool deploymentBranding;
221
226
230 readonly string slashCommandName;
231
236
240 CancellationTokenSource? gatewayCts;
241
245 TaskCompletionSource? gatewayReadyTcs;
246
250 Task<Result>? gatewayTask;
251
255 Snowflake currentUserId;
256
261
267 static string NormalizeMentions(string fromDiscord) => fromDiscord.Replace("<@!", "<@", StringComparison.Ordinal);
268
281 IAsyncDelayer asyncDelayer,
282 ILogger<DiscordProvider> logger,
284 IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions,
285 ChatBot chatBot,
286 Func<IEnumerable<string>> commandNamesFactory)
287 : this(jobManager, asyncDelayer, logger, assemblyInformationProvider, generalConfigurationOptions, chatBot, commandNamesFactory, null)
288 {
289 }
290
304 IAsyncDelayer asyncDelayer,
305 ILogger<DiscordProvider> logger,
307 IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions,
308 ChatBot chatBot,
309 Func<IEnumerable<string>>? commandNamesFactory,
310 ServiceProvider? serviceProviderOverride)
311 : base(jobManager, asyncDelayer, logger, chatBot)
312 {
313 this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
314 this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
315 this.commandNamesFactory = commandNamesFactory ?? (() => Array.Empty<string>());
316 slashCommandName = NormalizeSlashCommandName(generalConfigurationOptions.CurrentValue?.DiscordSlashCommandName);
317 Logger.LogInformation("Discord slash command configured as /{slashCommandName}.", slashCommandName);
318
319 mappedChannels = new List<ulong>();
320 connectDisconnectLock = new object();
321
322 var csb = new DiscordConnectionStringBuilder(chatBot.ConnectionString!);
323 var botToken = csb.BotToken!;
324 outputDisplayType = csb.DMOutputDisplay;
325 deploymentBranding = csb.DeploymentBranding;
326
327 serviceProvider = serviceProviderOverride ?? new ServiceCollection()
328 .AddDiscordGateway(serviceProvider => botToken)
329 .AddSingleton(serviceProvider => (IDiscordResponders)this)
330 .AddResponder<DiscordForwardingResponder>()
331 .BuildServiceProvider();
332 }
333
335 public override async ValueTask DisposeAsync()
336 {
337 lock (serviceProvider)
338 {
339 // serviceProvider can recursively dispose us
340 if (disposing)
341 return;
342 disposing = true;
343 }
344
345 await base.DisposeAsync();
346
347 await serviceProvider.DisposeAsync();
348
349 Logger.LogTrace("ServiceProvider disposed");
350
351 // this line is purely here to shutup CA2213. It should always be null
352 gatewayCts?.Dispose();
353
354 disposing = false;
355 }
356
358 public override async ValueTask SendMessage(Message? replyTo, MessageContent message, ulong channelId, CancellationToken cancellationToken)
359 {
360 ArgumentNullException.ThrowIfNull(message);
361
362 Optional<IMessageReference> replyToReference = default;
363 Optional<IAllowedMentions> allowedMentions = default;
364 if (replyTo != null && replyTo is DiscordMessage discordMessage)
365 {
366 replyToReference = discordMessage.MessageReference;
367 allowedMentions = new AllowedMentions(
368 Parse: new List<MentionType> // reset settings back to how discord acts if this is not passed (which is different than the default if empty)
369 {
370 MentionType.Everyone,
371 MentionType.Roles,
372 MentionType.Users,
373 },
374 MentionRepliedUser: false); // disable reply mentions
375 }
376
377 var embeds = ConvertEmbed(message.Embed);
378
379 if (replyTo is DiscordMessage { InteractionToken: { } interactionToken, ApplicationId: { } applicationId })
380 {
381 try
382 {
383 var interactionClient = serviceProvider.GetRequiredService<IDiscordRestInteractionAPI>();
384 Optional<IReadOnlyList<IEmbed>?> interactionEmbeds = embeds.HasValue
385 ? new Optional<IReadOnlyList<IEmbed>?>(embeds.Value)
386 : default;
387 Optional<IAllowedMentions?> interactionAllowedMentions = allowedMentions.HasValue
388 ? new Optional<IAllowedMentions?>(allowedMentions.Value)
389 : default;
390 var content = message.Text;
391 if (content == null)
392 {
393 Logger.LogWarning(
394 "Failed to send interaction response to channel {channelId}: Message was null!",
395 channelId);
396
397 content = "TGS: Could not send message to Discord. Message was `null`!";
398 }
399
400 var response = await interactionClient.EditOriginalInteractionResponseAsync(
401 applicationId,
402 interactionToken,
403 content,
404 embeds: interactionEmbeds,
405 allowedMentions: interactionAllowedMentions,
406 ct: cancellationToken);
407
408 if (!response.IsSuccess)
409 {
410 Logger.LogWarning(
411 "Failed to send interaction response to channel {channelId}: {result}",
412 channelId,
413 response.LogFormat());
414
415 if (response.Error is RestResultError<RestError> restError && restError.Error.Code == DiscordError.InvalidFormBody)
416 await interactionClient.EditOriginalInteractionResponseAsync(
417 applicationId,
418 interactionToken,
419 "TGS: Could not send message to Discord. Body was malformed or too long",
420 ct: cancellationToken);
421 }
422 }
423 catch (Exception e) when (e is not OperationCanceledException)
424 {
425 Logger.LogWarning(
426 e,
427 "Error sending interaction response to channel {channelId}",
428 channelId);
429 }
430
431 return;
432 }
433
434 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
435
436 async ValueTask SendToChannel(Snowflake channelId)
437 {
438 if (message.Text == null)
439 {
440 Logger.LogWarning(
441 "Failed to send to channel {channelId}: Message was null!",
442 channelId);
443
444 await channelsClient.CreateMessageAsync(
445 channelId,
446 "TGS: Could not send message to Discord. Message was `null`!",
447 messageReference: replyToReference,
448 allowedMentions: allowedMentions,
449 ct: cancellationToken);
450
451 return;
452 }
453
454 var result = await channelsClient.CreateMessageAsync(
455 channelId,
456 message.Text,
457 embeds: embeds,
458 messageReference: replyToReference,
459 allowedMentions: allowedMentions,
460 ct: cancellationToken);
461
462 if (!result.IsSuccess)
463 {
464 Logger.LogWarning(
465 "Failed to send to channel {channelId}: {result}",
466 channelId,
467 result.LogFormat());
468
469 if (result.Error is RestResultError<RestError> restError && restError.Error.Code == DiscordError.InvalidFormBody)
470 await channelsClient.CreateMessageAsync(
471 channelId,
472 "TGS: Could not send message to Discord. Body was malformed or too long",
473 messageReference: replyToReference,
474 allowedMentions: allowedMentions,
475 ct: cancellationToken);
476 }
477 }
478
479 try
480 {
481 if (channelId == 0)
482 {
483 IEnumerable<IChannel> unmappedTextChannels;
484 var allAccessibleTextChannels = await GetAllAccessibleTextChannels(cancellationToken);
485 lock (mappedChannels)
486 {
487 unmappedTextChannels = allAccessibleTextChannels
488 .Where(x => !mappedChannels.Contains(x.ID.Value))
489 .ToList();
490
491 var remapRequired = unmappedTextChannels.Any()
492 || mappedChannels.Any(
493 mappedChannel => !allAccessibleTextChannels.Any(
494 accessibleTextChannel => accessibleTextChannel.ID == new Snowflake(mappedChannel)));
495
496 if (remapRequired)
497 EnqueueMessage(null);
498 }
499
500 // discord API confirmed weak boned: https://stackoverflow.com/a/52462336
501 if (unmappedTextChannels.Any())
502 {
503 Logger.LogDebug("Dispatching to {count} unmapped channels...", unmappedTextChannels.Count());
505 unmappedTextChannels.Select(
506 x => SendToChannel(x.ID)));
507 }
508
509 return;
510 }
511
512 await SendToChannel(new Snowflake(channelId));
513 }
514 catch (Exception e) when (e is not OperationCanceledException)
515 {
516 Logger.LogWarning(e, "Error sending discord message!");
517 }
518 }
519
521 public override async ValueTask<Func<string?, string, ValueTask<Func<bool, ValueTask>>>> SendUpdateMessage(
522 Models.RevisionInformation revisionInformation,
523 Models.RevisionInformation? previousRevisionInformation,
524 EngineVersion engineVersion,
525 DateTimeOffset? estimatedCompletionTime,
526 string? gitHubOwner,
527 string? gitHubRepo,
528 ulong channelId,
529 bool localCommitPushed,
530 CancellationToken cancellationToken)
531 {
532 ArgumentNullException.ThrowIfNull(revisionInformation);
533 ArgumentNullException.ThrowIfNull(engineVersion);
534
535 localCommitPushed |= revisionInformation.CommitSha == revisionInformation.OriginCommitSha;
536
537 var fields = BuildUpdateEmbedFields(revisionInformation, previousRevisionInformation, engineVersion, gitHubOwner, gitHubRepo, localCommitPushed);
538 Optional<IEmbedAuthor> author = new EmbedAuthor(assemblyInformationProvider.VersionPrefix)
539 {
540 Url = "https://github.com/tgstation/tgstation-server",
541 IconUrl = "https://cdn.discordapp.com/attachments/1114451486374637629/1151650846019432448/tgs.png", // 404's in browsers but works in Discord
542 };
543 var embed = new Embed
544 {
545 Author = deploymentBranding ? author : default,
546 Colour = Color.FromArgb(0xF1, 0xC4, 0x0F),
547 Description = "TGS has begun deploying active repository code to production.",
548 Fields = fields,
549 Title = "Code Deployment",
550 Footer = new EmbedFooter(
551 $"In progress...{(estimatedCompletionTime.HasValue ? " ETA" : String.Empty)}"),
552 Timestamp = estimatedCompletionTime ?? default,
553 };
554
555 Logger.LogTrace("Attempting to post deploy embed to channel {channelId}...", channelId);
556 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
557
558 var prefix = GetEngineCompilerPrefix(engineVersion.Engine!.Value);
559 var messageResponse = await channelsClient.CreateMessageAsync(
560 new Snowflake(channelId),
561 $"{prefix}: Deployment in progress...",
562 embeds: new List<IEmbed> { embed },
563 ct: cancellationToken);
564
565 if (!messageResponse.IsSuccess)
566 Logger.LogWarning("Failed to post deploy embed to channel {channelId}: {result}", channelId, messageResponse.LogFormat());
567
568 return async (errorMessage, dreamMakerOutput) =>
569 {
570 var completionString = errorMessage == null ? "Pending" : "Failed";
571
572 Embed CreateUpdatedEmbed(string message, Color color) => new()
573 {
574 Author = embed.Author,
575 Colour = color,
576 Description = message,
577 Fields = fields,
578 Title = embed.Title,
579 Footer = new EmbedFooter(
580 completionString),
581 Timestamp = DateTimeOffset.UtcNow,
582 };
583
584 if (errorMessage == null)
585 embed = CreateUpdatedEmbed(
586 "The deployment completed successfully and will be available at the next server reboot.",
587 Color.Blue);
588 else
589 embed = CreateUpdatedEmbed(
590 "The deployment failed.",
591 Color.Red);
592
593 var showDMOutput = outputDisplayType switch
594 {
595 DiscordDMOutputDisplayType.Always => true,
596 DiscordDMOutputDisplayType.Never => false,
597 DiscordDMOutputDisplayType.OnError => errorMessage != null,
598 _ => throw new InvalidOperationException($"Invalid DiscordDMOutputDisplayType: {outputDisplayType}"),
599 };
600
601 if (dreamMakerOutput != null)
602 {
603 // https://github.com/discord-net/Discord.Net/blob/8349cd7e1eb92e9a3baff68082c30a7b43e8e9b7/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs#L431
604 const int MaxFieldValueLength = 1024;
605 showDMOutput = showDMOutput && dreamMakerOutput.Length < MaxFieldValueLength - (6 + Environment.NewLine.Length);
606 if (showDMOutput)
607 fields.Add(new EmbedField(
608 "Compiler Output",
609 $"```{Environment.NewLine}{dreamMakerOutput}{Environment.NewLine}```",
610 false));
611 }
612
613 if (errorMessage != null)
614 fields.Add(new EmbedField(
615 "Error Message",
616 errorMessage,
617 false));
618
619 var updatedMessageText = errorMessage == null ? $"{prefix}: Deployment pending reboot..." : $"{prefix}: Deployment failed!";
620
621 IMessage? updatedMessage = null;
622 async ValueTask CreateUpdatedMessage()
623 {
624 var createUpdatedMessageResponse = await channelsClient.CreateMessageAsync(
625 new Snowflake(channelId),
626 updatedMessageText,
627 embeds: new List<IEmbed> { embed },
628 ct: cancellationToken);
629
630 if (!createUpdatedMessageResponse.IsSuccess)
631 Logger.LogWarning(
632 "Creating updated deploy embed failed: {result}",
633 createUpdatedMessageResponse.LogFormat());
634 else
635 updatedMessage = createUpdatedMessageResponse.Entity;
636 }
637
638 if (!messageResponse.IsSuccess)
639 await CreateUpdatedMessage();
640 else
641 {
642 var editResponse = await channelsClient.EditMessageAsync(
643 new Snowflake(channelId),
644 messageResponse.Entity.ID,
645 updatedMessageText,
646 embeds: new List<IEmbed> { embed },
647 ct: cancellationToken);
648
649 if (!editResponse.IsSuccess)
650 {
651 Logger.LogWarning(
652 "Updating deploy embed {messageId} failed, attempting new post: {result}",
653 messageResponse.Entity.ID,
654 editResponse.LogFormat());
655 await CreateUpdatedMessage();
656 }
657 else
658 updatedMessage = editResponse.Entity;
659 }
660
661 return async (active) =>
662 {
663 if (updatedMessage == null || errorMessage != null)
664 return;
665
666 if (active)
667 {
668 completionString = "Succeeded";
669 updatedMessageText = $"{prefix}: Deployment succeeded!";
670 embed = CreateUpdatedEmbed(
671 "The deployment completed successfully and was applied to server.",
672 Color.Green);
673 }
674 else
675 {
676 completionString = "Inactive";
677 embed = CreateUpdatedEmbed(
678 "This deployment has been superceeded by a new one.",
679 Color.Gray);
680 }
681
682 var editResponse = await channelsClient.EditMessageAsync(
683 new Snowflake(channelId),
684 updatedMessage.ID,
685 updatedMessageText,
686 embeds: new List<IEmbed> { embed },
687 ct: cancellationToken);
688
689 if (!editResponse.IsSuccess)
690 Logger.LogWarning(
691 "Finalizing deploy embed {messageId} failed: {result}",
692 messageResponse.Entity.ID,
693 editResponse.LogFormat());
694 };
695 };
696 }
697
699 public async Task<Result> RespondAsync(IMessageCreate messageCreateEvent, CancellationToken cancellationToken)
700 {
701 ArgumentNullException.ThrowIfNull(messageCreateEvent);
702
703 if (!messageContentsAvailable && messageCreateEvent.GuildID.HasValue)
704 return Result.FromSuccess();
705
706 if ((messageCreateEvent.Type != MessageType.Default
707 && messageCreateEvent.Type != MessageType.InlineReply)
708 || messageCreateEvent.Author.ID == currentUserId)
709 return Result.FromSuccess();
710
711 var messageReference = new MessageReference
712 {
713 ChannelID = messageCreateEvent.ChannelID,
714 GuildID = messageCreateEvent.GuildID,
715 MessageID = messageCreateEvent.ID,
716 FailIfNotExists = false,
717 };
718
719 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
720 var channelResponse = await channelsClient.GetChannelAsync(messageCreateEvent.ChannelID, cancellationToken);
721 if (!channelResponse.IsSuccess)
722 {
723 Logger.LogWarning(
724 "Failed to get channel {channelId} in response to message {messageId}!",
725 messageCreateEvent.ChannelID,
726 messageCreateEvent.ID);
727
728 // we'll handle the errors ourselves
729 return Result.FromSuccess();
730 }
731
732 var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM;
733 var shouldNotAnswer = !pm;
734 if (shouldNotAnswer)
735 lock (mappedChannels)
736 shouldNotAnswer = !mappedChannels.Contains(messageCreateEvent.ChannelID.Value) && !mappedChannels.Contains(0);
737
738 var content = NormalizeMentions(messageCreateEvent.Content);
739 var mentionedUs = messageCreateEvent.Mentions.Any(x => x.ID == currentUserId)
740 || (!shouldNotAnswer && content.Split(' ').First().Equals(ChatManager.CommonMention, StringComparison.OrdinalIgnoreCase));
741
742 if (shouldNotAnswer)
743 {
744 if (mentionedUs)
745 Logger.LogTrace(
746 "Ignoring mention from {channelId} ({channelName}) by {authorId} ({authorName}). Channel not mapped!",
747 messageCreateEvent.ChannelID,
748 channelResponse.Entity.Name,
749 messageCreateEvent.Author.ID,
750 messageCreateEvent.Author.Username);
751
752 return Result.FromSuccess();
753 }
754
755 string guildName = "UNKNOWN";
756 if (!pm)
757 {
758 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
759 var messageGuildResponse = await guildsClient.GetGuildAsync(messageCreateEvent.GuildID.Value, false, cancellationToken);
760 if (messageGuildResponse.IsSuccess)
761 guildName = messageGuildResponse.Entity.Name;
762 else
763 Logger.LogWarning(
764 "Failed to get channel {channelID} in response to message {messageID}: {result}",
765 messageCreateEvent.ChannelID,
766 messageCreateEvent.ID,
767 messageGuildResponse.LogFormat());
768 }
769
770 var result = new DiscordMessage(
771 new ChatUser(
773 pm ? messageCreateEvent.Author.Username : guildName,
774 channelResponse.Entity.Name.Value!,
775 messageCreateEvent.ChannelID.Value)
776 {
777 IsPrivateChannel = pm,
778 EmbedsSupported = true,
779
780 // isAdmin and Tag populated by manager
781 },
782 messageCreateEvent.Author.Username,
783 NormalizeMentions($"<@{messageCreateEvent.Author.ID}>"),
784 messageCreateEvent.Author.ID.Value),
785 content,
786 messageReference);
787
788 EnqueueMessage(result);
789 return Result.FromSuccess();
790 }
791
793 public async Task<Result> RespondAsync(IInteractionCreate interactionCreateEvent, CancellationToken cancellationToken)
794 {
795 ArgumentNullException.ThrowIfNull(interactionCreateEvent);
796
797 var interactionData = interactionCreateEvent.Data;
798 if ((interactionCreateEvent.Type != InteractionType.ApplicationCommand
799 && interactionCreateEvent.Type != InteractionType.ApplicationCommandAutocomplete)
800 || !interactionData.HasValue
801 || !interactionData.Value.IsT0
802 || !interactionData.Value.AsT0.Name.Equals(slashCommandName, StringComparison.OrdinalIgnoreCase))
803 return Result.FromSuccess();
804
805 var commandData = interactionData.Value.AsT0;
806 var interactionClient = serviceProvider.GetRequiredService<IDiscordRestInteractionAPI>();
807 Task RespondWithError(string message)
808 => interactionClient.EditOriginalInteractionResponseAsync(
809 interactionCreateEvent.ApplicationID,
810 interactionCreateEvent.Token,
811 message,
812 ct: cancellationToken);
813
814 if (interactionCreateEvent.Type == InteractionType.ApplicationCommandAutocomplete)
815 return await RespondAutocomplete(interactionCreateEvent, commandData, interactionClient, cancellationToken);
816
817 var deferResponse = await interactionClient.CreateInteractionResponseAsync(
818 interactionCreateEvent.ID,
819 interactionCreateEvent.Token,
820 new InteractionResponse(InteractionCallbackType.DeferredChannelMessageWithSource),
821 ct: cancellationToken);
822 if (!deferResponse.IsSuccess)
823 {
824 Logger.LogWarning(
825 "Failed to defer slash command response {interactionId}: {result}",
826 interactionCreateEvent.ID,
827 deferResponse.LogFormat());
828 return Result.FromSuccess();
829 }
830
831 var commandText = GetStringOption(commandData, SlashCommandOptionName);
832 if (String.IsNullOrWhiteSpace(commandText))
833 {
834 await RespondWithError("TGS: No command text supplied.");
835 return Result.FromSuccess();
836 }
837
838 var arguments = GetStringOption(commandData, SlashCommandArgumentsOptionName);
839
840 if (!interactionCreateEvent.Channel.HasValue || !interactionCreateEvent.Channel.Value.ID.HasValue)
841 {
842 await RespondWithError("TGS: Processing error, check logs!");
843 return Result.FromSuccess();
844 }
845
846 var channelId = interactionCreateEvent.Channel.Value.ID.Value;
847 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
848 var channelResponse = await channelsClient.GetChannelAsync(channelId, cancellationToken);
849 if (!channelResponse.IsSuccess)
850 {
851 Logger.LogWarning(
852 "Failed to get channel {channelId} in response to interaction {interactionId}: {result}",
853 channelId,
854 interactionCreateEvent.ID,
855 channelResponse.LogFormat());
856
857 await RespondWithError("TGS: Processing error, check logs!");
858 return Result.FromSuccess();
859 }
860
861 var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM;
862 var shouldNotAnswer = !pm;
863 if (shouldNotAnswer)
864 lock (mappedChannels)
865 shouldNotAnswer = !mappedChannels.Contains(channelId.Value) && !mappedChannels.Contains(0);
866
867 if (shouldNotAnswer)
868 {
869 await RespondWithError("TGS: This channel is not mapped.");
870 return Result.FromSuccess();
871 }
872
873 string guildName = "UNKNOWN";
874 if (!pm)
875 {
876 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
877 var guildResponse = await guildsClient.GetGuildAsync(interactionCreateEvent.GuildID.Value, false, cancellationToken);
878 if (guildResponse.IsSuccess)
879 guildName = guildResponse.Entity.Name;
880 else
881 Logger.LogWarning(
882 "Failed to get guild {guildId} in response to interaction {interactionId}: {result}",
883 interactionCreateEvent.GuildID.Value,
884 interactionCreateEvent.ID,
885 guildResponse.LogFormat());
886 }
887
888 var user = interactionCreateEvent.Member.HasValue && interactionCreateEvent.Member.Value.User.HasValue
889 ? interactionCreateEvent.Member.Value.User.Value
890 : interactionCreateEvent.User.Value;
891 var result = new DiscordMessage(
892 new ChatUser(
894 pm ? user.Username : guildName,
895 channelResponse.Entity.Name.Value!,
896 channelId.Value)
897 {
898 IsPrivateChannel = pm,
899 EmbedsSupported = true,
900 },
901 user.Username,
902 NormalizeMentions($"<@{user.ID}>"),
903 user.ID.Value),
904 BuildSlashCommandMessageContent(commandText, arguments),
905 default,
906 interactionCreateEvent.ApplicationID,
907 interactionCreateEvent.Token);
908
909 EnqueueMessage(result);
910 return Result.FromSuccess();
911 }
912
914 public Task<Result> RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
915 {
916 ArgumentNullException.ThrowIfNull(readyEvent);
917
918 Logger.LogTrace("Gatway ready. Version: {version}", readyEvent.Version);
919 gatewayReadyTcs?.TrySetResult();
920 return Task.FromResult(Result.FromSuccess());
921 }
922
924 protected override async ValueTask Connect(CancellationToken cancellationToken)
925 {
926 try
927 {
929 {
930 if (gatewayCts != null)
931 throw new InvalidOperationException("Discord gateway still active!");
932
933 gatewayCts = new CancellationTokenSource();
934 }
935
936 var gatewayCancellationToken = gatewayCts.Token;
937 Task<Result>? localGatewayTask = null;
938 using var localCombinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, gatewayCancellationToken);
939 var localCombinedCancellationToken = localCombinedCts.Token;
940
941 try
942 {
943 var applicationClient = serviceProvider.GetRequiredService<IDiscordRestApplicationAPI>();
944 var currentApplicationResponse = await applicationClient.GetCurrentApplicationAsync(localCombinedCancellationToken);
945 if (currentApplicationResponse.IsSuccess)
946 {
947 var application = currentApplicationResponse.Entity;
948 SetMessageContentsAvailable(application.Flags.HasValue && HasMessageContentIntent(application.Flags.Value));
949 await RegisterSlashCommand(application.ID, localCombinedCancellationToken);
950 }
951 else
952 {
953 localCombinedCancellationToken.ThrowIfCancellationRequested();
954 Logger.LogWarning(
955 "Unable to retrieve current Discord application. Retrying connection: {result}",
956 currentApplicationResponse.LogFormat());
957 throw new JobException(
958 ErrorCode.ChatCannotConnectProvider,
959 new InvalidOperationException($"Discord application request failed: {currentApplicationResponse.LogFormat()}"));
960 }
961
962 var gatewayClient = serviceProvider.GetRequiredService<DiscordGatewayClient>();
963
964 gatewayReadyTcs = new TaskCompletionSource();
965
966 using var gatewayConnectionAbortRegistration = cancellationToken.Register(() => gatewayReadyTcs.TrySetCanceled(cancellationToken));
967 gatewayCancellationToken.Register(() => Logger.LogTrace("Stopping gateway client..."));
968
969 // reconnects keep happening until we stop or it faults, our auto reconnector will handle the latter
970 localGatewayTask = gatewayClient.RunAsync(gatewayCancellationToken);
971 await Task.WhenAny(gatewayReadyTcs.Task, localGatewayTask);
972
973 cancellationToken.ThrowIfCancellationRequested();
974 if (localGatewayTask.IsCompleted)
975 {
976 var gatewayResult = await localGatewayTask;
977 Logger.LogWarning("Discord gateway stopped before becoming ready: {result}", gatewayResult.LogFormat());
978 throw new JobException(
979 ErrorCode.ChatCannotConnectProvider,
980 new InvalidOperationException($"Discord gateway failed: {gatewayResult.LogFormat()}"));
981 }
982
983 var userClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
984 var currentUserResult = await userClient.GetCurrentUserAsync(localCombinedCancellationToken);
985 if (!currentUserResult.IsSuccess)
986 {
987 localCombinedCancellationToken.ThrowIfCancellationRequested();
988 Logger.LogWarning("Unable to retrieve current user: {result}", currentUserResult.LogFormat());
989 throw new JobException(
990 ErrorCode.ChatCannotConnectProvider,
991 new InvalidOperationException($"Discord current-user request failed: {currentUserResult.LogFormat()}"));
992 }
993
994 currentUserId = currentUserResult.Entity.ID;
995 }
996 finally
997 {
998 gatewayTask = localGatewayTask;
999 }
1000 }
1001 catch
1002 {
1003 // will handle cleanup
1004 // DCT: Musn't abort
1005 await DisconnectImpl(CancellationToken.None);
1006 throw;
1007 }
1008 }
1009
1011 protected override async ValueTask DisconnectImpl(CancellationToken cancellationToken)
1012 {
1013 Task<Result> localGatewayTask;
1014 CancellationTokenSource localGatewayCts;
1016 {
1017 localGatewayTask = gatewayTask!;
1018 localGatewayCts = gatewayCts!;
1019 gatewayTask = null;
1020 gatewayCts = null;
1021 if (localGatewayTask == null)
1022 {
1023 localGatewayCts?.Dispose();
1024 return;
1025 }
1026 }
1027
1028 localGatewayCts.Cancel();
1029 var gatewayResult = await localGatewayTask;
1030
1031 Logger.LogTrace("Gateway task complete");
1032 if (!gatewayResult.IsSuccess)
1033 Logger.LogWarning("Gateway issue: {result}", gatewayResult.LogFormat());
1034
1035 localGatewayCts.Dispose();
1036 }
1037
1039 protected override async ValueTask<Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> MapChannelsImpl(IEnumerable<Models.ChatChannel> channels, CancellationToken cancellationToken)
1040 {
1041 ArgumentNullException.ThrowIfNull(channels);
1042
1043 var remapRequired = false;
1044 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
1045 var guildTasks = new ConcurrentDictionary<Snowflake, Task<Result<IGuild>>>();
1046
1047 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>?> GetModelChannelFromDBChannel(Models.ChatChannel channelFromDB)
1048 {
1049 if (!channelFromDB.DiscordChannelId.HasValue)
1050 throw new InvalidOperationException("ChatChannel missing DiscordChannelId!");
1051
1052 var channelId = channelFromDB.DiscordChannelId.Value;
1053 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
1054 var discordChannelResponse = await channelsClient.GetChannelAsync(new Snowflake(channelId), cancellationToken);
1055 if (!discordChannelResponse.IsSuccess)
1056 {
1057 Logger.LogWarning(
1058 "Error retrieving discord channel {channelId}: {result}",
1059 channelId,
1060 discordChannelResponse.LogFormat());
1061
1062 var remapConditional = !(discordChannelResponse.Error is RestResultError<RestError> restResultError
1063 && (restResultError.Error?.Code == DiscordError.MissingAccess
1064 || restResultError.Error?.Code == DiscordError.UnknownChannel));
1065
1066 if (remapConditional)
1067 {
1068 Logger.Log(
1069 remapRequired
1070 ? LogLevel.Trace
1071 : LogLevel.Debug,
1072 "Error on channel {channelId} is not an access/thread issue. Will retry remap...",
1073 channelId);
1074 remapRequired = true;
1075 }
1076
1077 return null;
1078 }
1079
1080 var channelType = discordChannelResponse.Entity.Type;
1081 if (!SupportedGuildChannelTypes.Contains(channelType))
1082 {
1083 Logger.LogWarning("Cound not map channel {channelId}! Incorrect type: {channelType}", channelId, discordChannelResponse.Entity.Type);
1084 return null;
1085 }
1086
1087 var guildId = discordChannelResponse.Entity.GuildID.Value;
1088
1089 var added = false;
1090 var guildsResponse = await guildTasks.GetOrAdd(
1091 guildId,
1092 localGuildId =>
1093 {
1094 added = true;
1095 return guildsClient.GetGuildAsync(
1096 localGuildId,
1097 false,
1098 cancellationToken);
1099 });
1100 if (!guildsResponse.IsSuccess)
1101 {
1102 if (added)
1103 {
1104 Logger.LogWarning(
1105 "Error retrieving discord guild {guildID}: {result}",
1106 guildId,
1107 guildsResponse.LogFormat());
1108 remapRequired = true;
1109 }
1110
1111 return null;
1112 }
1113
1114 var connectionName = guildsResponse.Entity.Name;
1115
1116 var channelModel = new ChannelRepresentation(
1117 guildsResponse.Entity.Name,
1118 discordChannelResponse.Entity.Name.Value!,
1119 channelId)
1120 {
1121 IsAdminChannel = channelFromDB.IsAdminChannel == true,
1122 IsPrivateChannel = false,
1123 Tag = channelFromDB.Tag,
1124 EmbedsSupported = true,
1125 };
1126
1127 Logger.LogTrace("Mapped channel {realId}: {friendlyName}", channelModel.RealId, channelModel.FriendlyName);
1128 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
1129 channelFromDB,
1130 new List<ChannelRepresentation> { channelModel });
1131 }
1132
1133 var tasks = channels
1134 .Where(x => x.DiscordChannelId != 0)
1135 .Select(GetModelChannelFromDBChannel);
1136
1137 var channelTuples = await ValueTaskExtensions.WhenAll(tasks.ToList());
1138
1139 var list = channelTuples
1140 .Where(x => x != null)
1141 .Cast<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>() // NRT my beloathed
1142 .ToList();
1143
1144 var channelIdZeroModel = channels.FirstOrDefault(x => x.DiscordChannelId == 0);
1145 if (channelIdZeroModel != null)
1146 {
1147 Logger.LogInformation("Mapping ALL additional accessible text channels");
1148 var allAccessibleChannels = await GetAllAccessibleTextChannels(cancellationToken);
1149 var unmappedTextChannels = allAccessibleChannels
1150 .Where(x => !tasks.Any(task => task.Result != null && new Snowflake(task.Result.Item1.DiscordChannelId!.Value) == x.ID));
1151
1152 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> CreateMappingsForUnmappedChannels()
1153 {
1154 var unmappedTasks =
1155 unmappedTextChannels.Select(
1156 async unmappedTextChannel =>
1157 {
1158 var fakeChannelModel = new Models.ChatChannel
1159 {
1160 DiscordChannelId = unmappedTextChannel.ID.Value,
1161 IsAdminChannel = channelIdZeroModel.IsAdminChannel,
1162 Tag = channelIdZeroModel.Tag,
1163 };
1164
1165 var tuple = await GetModelChannelFromDBChannel(fakeChannelModel);
1166 return tuple?.Item2.First();
1167 })
1168 .ToList();
1169
1170 // Add catch-all channel
1171 unmappedTasks.Add(Task.FromResult<ChannelRepresentation?>(
1173 "(Unknown Discord Guilds)",
1174 "(Unknown Discord Channels)",
1175 0)
1176 {
1177 IsAdminChannel = channelIdZeroModel.IsAdminChannel!.Value,
1178 EmbedsSupported = true,
1179 Tag = channelIdZeroModel.Tag,
1180 }));
1181
1182 await Task.WhenAll(unmappedTasks);
1183 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
1184 channelIdZeroModel,
1185 unmappedTasks
1186 .Select(x => x.Result)
1187 .Where(x => x != null)
1188 .Cast<ChannelRepresentation>() // NRT my beloathed
1189 .ToList());
1190 }
1191
1192 var task = CreateMappingsForUnmappedChannels();
1193 var tuple = await task;
1194 list.Add(tuple);
1195 }
1196
1197 lock (mappedChannels)
1198 {
1199 mappedChannels.Clear();
1200 mappedChannels.AddRange(list.SelectMany(x => x.Item2).Select(x => x.RealId));
1201 }
1202
1203 if (remapRequired)
1204 {
1205 Logger.LogWarning("Some channels failed to load with unknown errors. We will request that these be remapped, but it may result in communication spam. Please check prior logs and report an issue if this occurs.");
1206 EnqueueMessage(null);
1207 }
1208
1209 return new Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(list.Select(x => new KeyValuePair<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(x.Item1, x.Item2)));
1210 }
1211
1216 void SetMessageContentsAvailable(bool available)
1217 {
1218 messageContentsAvailable = available;
1219 var gatewayOptions = serviceProvider.GetRequiredService<IOptions<DiscordGatewayClientOptions>>().Value;
1220 if (available)
1221 gatewayOptions.Intents |= GatewayIntents.MessageContents;
1222 else
1223 gatewayOptions.Intents &= ~GatewayIntents.MessageContents;
1224 }
1225
1231 async ValueTask<IEnumerable<IChannel>> GetAllAccessibleTextChannels(CancellationToken cancellationToken)
1232 {
1233 var usersClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
1234 var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken);
1235 if (!currentGuildsResponse.IsSuccess)
1236 {
1237 Logger.LogWarning(
1238 "Error retrieving current discord guilds: {result}",
1239 currentGuildsResponse.LogFormat());
1240 return Enumerable.Empty<IChannel>();
1241 }
1242
1243 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
1244
1245 async ValueTask<IEnumerable<IChannel>> GetGuildChannels(IPartialGuild guild)
1246 {
1247 var channelsTask = guildsClient.GetGuildChannelsAsync(guild.ID.Value, cancellationToken);
1248 var threads = await guildsClient.ListActiveGuildThreadsAsync(guild.ID.Value, cancellationToken);
1249 if (!threads.IsSuccess)
1250 Logger.LogWarning(
1251 "Error retrieving discord guild threads {guildId} ({guildName}): {result}",
1252 guild.ID,
1253 guild.Name,
1254 threads.LogFormat());
1255
1256 var channels = await channelsTask;
1257 if (!channels.IsSuccess)
1258 Logger.LogWarning(
1259 "Error retrieving discord guild channels {guildId} ({guildName}): {result}",
1260 guild.ID,
1261 guild.Name,
1262 channels.LogFormat());
1263
1264 if (!channels.IsSuccess && !threads.IsSuccess)
1265 return Enumerable.Empty<IChannel>();
1266
1267 if (channels.IsSuccess && threads.IsSuccess)
1268 return channels.Entity.Concat(threads.Entity.Threads ?? Enumerable.Empty<IChannel>());
1269
1270 return channels.Entity ?? threads.Entity?.Threads ?? Enumerable.Empty<IChannel>();
1271 }
1272
1273 var guildsChannelsTasks = currentGuildsResponse.Entity
1274 .Select(GetGuildChannels);
1275
1276 var guildsChannels = await ValueTaskExtensions.WhenAll(guildsChannelsTasks, currentGuildsResponse.Entity.Count);
1277
1278 var allAccessibleChannels = guildsChannels
1279 .SelectMany(channels => channels)
1280 .Where(guildChannel => SupportedGuildChannelTypes.Contains(guildChannel.Type));
1281
1282 return allAccessibleChannels;
1283 }
1284
1293 async Task<Result> RespondAutocomplete(
1294 IInteractionCreate interactionCreateEvent,
1295 IApplicationCommandData commandData,
1296 IDiscordRestInteractionAPI interactionClient,
1297 CancellationToken cancellationToken)
1298 {
1299 var focusedCommand = commandData.Options.HasValue
1300 ? commandData.Options.Value.FirstOrDefault(option => option.Name.Equals(SlashCommandOptionName, StringComparison.Ordinal) && option.IsFocused.HasValue && option.IsFocused.Value)
1301 : null;
1302 var focusedValue = focusedCommand != null && focusedCommand.Value.HasValue && focusedCommand.Value.Value.IsT0
1303 ? focusedCommand.Value.Value.AsT0
1304 : String.Empty;
1305
1306 var choices = commandNamesFactory()
1307 .Where(command => String.IsNullOrEmpty(focusedValue) || command.StartsWith(focusedValue, StringComparison.OrdinalIgnoreCase))
1309 .Select(command => (IApplicationCommandOptionChoice)new ApplicationCommandOptionChoice(command, OneOf<string, int, double>.FromT0(command)))
1310 .ToList();
1311
1312 var response = await interactionClient.CreateInteractionResponseAsync(
1313 interactionCreateEvent.ID,
1314 interactionCreateEvent.Token,
1315 new InteractionResponse(
1316 InteractionCallbackType.ApplicationCommandAutocompleteResult,
1317 new Optional<OneOf<IInteractionMessageCallbackData, IInteractionAutocompleteCallbackData, IInteractionModalCallbackData>>(
1318 OneOf<IInteractionMessageCallbackData, IInteractionAutocompleteCallbackData, IInteractionModalCallbackData>.FromT1(
1319 new InteractionAutocompleteCallbackData(choices)))),
1320 ct: cancellationToken);
1321
1322 if (!response.IsSuccess)
1323 Logger.LogWarning(
1324 "Failed to respond to slash command autocomplete {interactionId}: {result}",
1325 interactionCreateEvent.ID,
1326 response.LogFormat());
1327
1328 return Result.FromSuccess();
1329 }
1330
1337 async ValueTask RegisterSlashCommand(Snowflake applicationId, CancellationToken cancellationToken)
1338 {
1339 var applicationClient = serviceProvider.GetRequiredService<IDiscordRestApplicationAPI>();
1340 var updateResponse = await applicationClient.CreateGlobalApplicationCommandAsync(
1341 applicationId,
1344 new Optional<IReadOnlyList<IApplicationCommandOption>>(SlashCommandOptions),
1345 ct: cancellationToken);
1346
1347 if (!updateResponse.IsSuccess)
1348 {
1349 cancellationToken.ThrowIfCancellationRequested();
1350 Logger.LogWarning("Unable to register Discord slash command: {result}", updateResponse.LogFormat());
1351 throw new JobException(
1352 ErrorCode.ChatCannotConnectProvider,
1353 new InvalidOperationException($"Discord slash-command registration failed: {updateResponse.LogFormat()}"));
1354 }
1355
1356 Logger.LogInformation("Registered global Discord slash command /{slashCommandName}.", slashCommandName);
1357
1358 var globalCommandsResponse = await applicationClient.GetGlobalApplicationCommandsAsync(applicationId, ct: cancellationToken);
1359 if (globalCommandsResponse.IsSuccess)
1360 foreach (var command in globalCommandsResponse.Entity.Where(command => IsTgsSlashCommand(command, slashCommandName)))
1361 {
1362 var deleteResponse = await applicationClient.DeleteGlobalApplicationCommandAsync(applicationId, command.ID, cancellationToken);
1363 if (!deleteResponse.IsSuccess)
1364 Logger.LogWarning("Unable to remove stale global Discord slash command {commandId}: {result}", command.ID, deleteResponse.LogFormat());
1365 else
1366 Logger.LogInformation("Removed stale global Discord slash command /{commandName}.", command.Name);
1367 }
1368 else
1369 Logger.LogWarning("Unable to inspect global Discord slash commands for stale entries: {result}", globalCommandsResponse.LogFormat());
1370 }
1371
1382 List<IEmbedField> BuildUpdateEmbedFields(
1383 Models.RevisionInformation revisionInformation,
1384 Models.RevisionInformation? previousRevisionInformation,
1385 EngineVersion engineVersion,
1386 string? gitHubOwner,
1387 string? gitHubRepo,
1388 bool localCommitPushed)
1389 {
1390 bool gitHub = gitHubOwner != null && gitHubRepo != null;
1391 var engineField = engineVersion.Engine!.Value switch
1392 {
1393 EngineType.Byond => new EmbedField(
1394 "BYOND Version",
1395 $"{engineVersion.Version!.Major}.{engineVersion.Version.Minor}{(engineVersion.CustomIteration.HasValue ? $".{engineVersion.CustomIteration.Value}" : String.Empty)}",
1396 true),
1397 EngineType.OpenDream => new EmbedField(
1398 "OpenDream Version",
1399 $"[{engineVersion.SourceSHA![..7]}]({generalConfigurationOptions.CurrentValue.OpenDreamGitUrl}/commit/{engineVersion.SourceSHA})",
1400 true),
1401 _ => throw new InvalidOperationException($"Invaild EngineType: {engineVersion.Engine.Value}"),
1402 };
1403
1404 var revisionSha = revisionInformation.CommitSha!;
1405 var revisionOriginSha = revisionInformation.OriginCommitSha!;
1406 var fields = new List<IEmbedField>
1407 {
1408 engineField,
1409 };
1410
1411 if (gitHubOwner == null || gitHubRepo == null)
1412 return fields;
1413
1414 var previousTestMerges = (IEnumerable<RevInfoTestMerge>?)previousRevisionInformation?.ActiveTestMerges ?? Enumerable.Empty<RevInfoTestMerge>();
1415 var currentTestMerges = (IEnumerable<RevInfoTestMerge>?)revisionInformation.ActiveTestMerges ?? Enumerable.Empty<RevInfoTestMerge>();
1416
1417 // determine what TMs were changed and how
1418 var addedTestMerges = currentTestMerges
1419 .Select(x => x.TestMerge)
1420 .Where(x => !previousTestMerges
1421 .Any(y => y.TestMerge.Number == x.Number))
1422 .ToList();
1423 var removedTestMerges = previousTestMerges
1424 .Select(x => x.TestMerge)
1425 .Where(x => !currentTestMerges
1426 .Any(y => y.TestMerge.Number == x.Number))
1427 .ToList();
1428 var updatedTestMerges = currentTestMerges
1429 .Select(x => x.TestMerge)
1430 .Where(x => previousTestMerges
1431 .Any(y => y.TestMerge.Number == x.Number && y.TestMerge.TargetCommitSha != x.TargetCommitSha))
1432 .ToList();
1433 var unchangedTestMerges = currentTestMerges
1434 .Select(x => x.TestMerge)
1435 .Where(x => previousTestMerges
1436 .Any(y => y.TestMerge.Number == x.Number && y.TestMerge.TargetCommitSha == x.TargetCommitSha))
1437 .ToList();
1438
1439 fields.Add(
1440 new EmbedField(
1441 "Local Commit",
1442 localCommitPushed && gitHub
1443 ? $"[{revisionSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionSha})"
1444 : revisionSha[..7],
1445 true));
1446
1447 fields.Add(
1448 new EmbedField(
1449 "Branch Commit",
1450 gitHub
1451 ? $"[{revisionOriginSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionOriginSha})"
1452 : revisionOriginSha[..7],
1453 true));
1454
1455 fields.AddRange(addedTestMerges
1456 .Select(x => new EmbedField(
1457 $"#{x.Number} (Added)",
1458 $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.TargetCommitSha![..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.TargetCommitSha}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}",
1459 false)));
1460
1461 fields.AddRange(updatedTestMerges
1462 .Select(x => new EmbedField(
1463 $"#{x.Number} (Updated)",
1464 $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.TargetCommitSha![..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.TargetCommitSha}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}",
1465 false)));
1466
1467 fields.AddRange(unchangedTestMerges
1468 .Select(x => new EmbedField(
1469 $"#{x.Number}",
1470 $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.TargetCommitSha![..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.TargetCommitSha}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}",
1471 false)));
1472
1473 if (removedTestMerges.Count != 0)
1474 fields.Add(
1475 new EmbedField(
1476 "Removed:",
1477 String.Join(
1478 Environment.NewLine,
1479 removedTestMerges
1480 .Select(x => $"- #{x.Number} [{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_"))));
1481
1482 return fields;
1483 }
1484
1490#pragma warning disable CA1502
1491 Optional<IReadOnlyList<IEmbed>> ConvertEmbed(ChatEmbed? embed)
1492 {
1493 if (embed == null)
1494 return default;
1495
1496 var embedErrors = new List<string>();
1497 Optional<Color> colour = default;
1498 if (embed.Colour != null)
1499 if (Int32.TryParse(embed.Colour[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var argb))
1500 colour = Color.FromArgb(argb);
1501 else
1502 embedErrors.Add(
1503 String.Format(
1504 CultureInfo.InvariantCulture,
1505 "Invalid embed colour: {0}",
1506 embed.Colour));
1507
1508 if (embed.Author != null && String.IsNullOrWhiteSpace(embed.Author.Name))
1509 {
1510 embedErrors.Add("Null or whitespace embed author name!");
1511 embed.Author = null;
1512 }
1513
1514 List<IEmbedField>? fields = null;
1515 if (embed.Fields != null)
1516 {
1517 fields = new List<IEmbedField>();
1518 var i = -1;
1519 foreach (var field in embed.Fields)
1520 {
1521 ++i;
1522 var invalid = false;
1523 if (String.IsNullOrWhiteSpace(field.Name))
1524 {
1525 embedErrors.Add(
1526 String.Format(
1527 CultureInfo.InvariantCulture,
1528 "Null or whitespace field name at index {0}!",
1529 i));
1530 invalid = true;
1531 }
1532
1533 if (String.IsNullOrWhiteSpace(field.Value))
1534 {
1535 embedErrors.Add(
1536 String.Format(
1537 CultureInfo.InvariantCulture,
1538 "Null or whitespace field value at index {0}!",
1539 i));
1540 invalid = true;
1541 }
1542
1543 if (invalid)
1544 continue;
1545
1546 fields.Add(new EmbedField(field.Name!, field.Value!)
1547 {
1548 IsInline = field.IsInline ?? default(Optional<bool>),
1549 });
1550 }
1551 }
1552
1553 if (embed.Footer != null && String.IsNullOrWhiteSpace(embed.Footer.Text))
1554 {
1555 embedErrors.Add("Null or whitespace embed footer text!");
1556 embed.Footer = null;
1557 }
1558
1559 if (embed.Image != null && String.IsNullOrWhiteSpace(embed.Image.Url))
1560 {
1561 embedErrors.Add("Null or whitespace embed image url!");
1562 embed.Image = null;
1563 }
1564
1565 if (embed.Thumbnail != null && String.IsNullOrWhiteSpace(embed.Thumbnail.Url))
1566 {
1567 embedErrors.Add("Null or whitespace embed thumbnail url!");
1568 embed.Thumbnail = null;
1569 }
1570
1571 Optional<DateTimeOffset> timestampOptional = default;
1572 if (embed.Timestamp != null)
1573 if (DateTimeOffset.TryParse(embed.Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var timestamp))
1574 timestampOptional = timestamp.ToUniversalTime();
1575 else
1576 embedErrors.Add(
1577 String.Format(
1578 CultureInfo.InvariantCulture,
1579 "Invalid embed timestamp: {0}",
1580 embed.Timestamp));
1581
1582 var discordEmbed = new Embed
1583 {
1584 Author = embed.Author != null
1585 ? new EmbedAuthor(embed.Author.Name!)
1586 {
1587 IconUrl = embed.Author.IconUrl ?? default(Optional<string>),
1588 ProxyIconUrl = embed.Author.ProxyIconUrl ?? default(Optional<string>),
1589 Url = embed.Author.Url ?? default(Optional<string>),
1590 }
1591 : default(Optional<IEmbedAuthor>),
1592 Colour = colour,
1593 Description = embed.Description ?? default(Optional<string>),
1594 Fields = fields ?? default(Optional<IReadOnlyList<IEmbedField>>),
1595 Footer = embed.Footer != null
1596 ? (Optional<IEmbedFooter>)new EmbedFooter(embed.Footer.Text!)
1597 {
1598 IconUrl = embed.Footer.IconUrl ?? default(Optional<string>),
1599 ProxyIconUrl = embed.Footer.ProxyIconUrl ?? default(Optional<string>),
1600 }
1601 : default,
1602 Image = embed.Image != null
1603 ? new EmbedImage(embed.Image.Url!)
1604 {
1605 Width = embed.Image.Width ?? default(Optional<int>),
1606 Height = embed.Image.Height ?? default(Optional<int>),
1607 ProxyUrl = embed.Image.ProxyUrl ?? default(Optional<string>),
1608 }
1609 : default(Optional<IEmbedImage>),
1610 Provider = embed.Provider != null
1611 ? new EmbedProvider
1612 {
1613 Name = embed.Provider.Name ?? default(Optional<string>),
1614 Url = embed.Provider.Url ?? default(Optional<string>),
1615 }
1616 : default(Optional<IEmbedProvider>),
1617 Thumbnail = embed.Thumbnail != null
1618 ? new EmbedThumbnail(embed.Thumbnail.Url!)
1619 {
1620 Width = embed.Thumbnail.Width ?? default(Optional<int>),
1621 Height = embed.Thumbnail.Height ?? default(Optional<int>),
1622 ProxyUrl = embed.Thumbnail.ProxyUrl ?? default(Optional<string>),
1623 }
1624 : default(Optional<IEmbedThumbnail>),
1625 Timestamp = timestampOptional,
1626 Title = embed.Title ?? default(Optional<string>),
1627 Url = embed.Url ?? default(Optional<string>),
1628 Video = embed.Video != null
1629 ? new EmbedVideo
1630 {
1631 Url = embed.Video.Url ?? default(Optional<string>),
1632 Width = embed.Video.Width ?? default(Optional<int>),
1633 Height = embed.Video.Height ?? default(Optional<int>),
1634 ProxyUrl = embed.Video.ProxyUrl ?? default(Optional<string>),
1635 }
1636 : default(Optional<IEmbedVideo>),
1637 };
1638
1639 var result = new List<IEmbed> { discordEmbed };
1640
1641 if (embedErrors.Count > 0)
1642 {
1643 var joinedErrors = String.Join(Environment.NewLine, embedErrors);
1644 Logger.LogError("Embed description contains errors:{newLine}{issues}", Environment.NewLine, joinedErrors);
1645 result.Add(new Embed
1646 {
1647 Title = "TGS Embed Errors",
1648 Description = joinedErrors,
1649 Colour = Color.Red,
1650 Footer = new EmbedFooter("Please report this to your codebase's maintainers."),
1651 Timestamp = DateTimeOffset.UtcNow,
1652 });
1653 }
1654
1655 return result;
1656 }
1657 #pragma warning restore CA1502
1658 }
1659#pragma warning restore CA1506
1660}
Optional< IReadOnlyList< IEmbed > > ConvertEmbed(ChatEmbed? embed)
Convert a ChatEmbed to an IEmbed parameters.
ChatConnectionStringBuilder for ChatProvider.Discord.
Information about an engine installation.
int? CustomIteration
The revision of the custom build.
string? ConnectionString
The information used to connect to the Provider.
bool? IsAdminChannel
If the ChatChannel is an admin channel.
Extension methods for the ValueTask and ValueTask<TResult> classes.
static async ValueTask WhenAll(IEnumerable< ValueTask > tasks)
Fully await a given list of tasks .
bool IsAdminChannel
If this is considered a channel for admin commands.
const string CommonMention
The common bot mention.
Represents a tgs_chat_user datum.
Definition ChatUser.cs:12
An IResponder<TGatewayEvent> that forwards to another targetResponder.
A Message containing the source IMessageReference.
readonly object connectDisconnectLock
Lock object used to sychronize connect/disconnect operations.
async Task< Result > RespondAsync(IInteractionCreate interactionCreateEvent, CancellationToken cancellationToken)
readonly List< ulong > mappedChannels
List<T> of mapped channel Snowflakes.
const string SlashCommandArgumentsOptionName
The Discord slash command arguments option name.
static ? string GetStringOption(IApplicationCommandData commandData, string optionName)
Gets a string option from commandData .
override async ValueTask SendMessage(Message? replyTo, MessageContent message, ulong channelId, CancellationToken cancellationToken)
Send a message to the IProvider.A ValueTask representing the running operation.
readonly bool deploymentBranding
If the tgstation-server logo is shown in deployment embeds.
static readonly ChannelType[] SupportedGuildChannelTypes
The ChannelTypes supported by the DiscordProvider for mapping.
static readonly IReadOnlyList< IApplicationCommandOption > SlashCommandOptions
The Discord slash command options.
static string BuildSlashCommandMessageContent(string commandName, string? arguments=null)
Builds the existing chat command text for a Discord slash command.
DiscordProvider(IJobManager jobManager, IAsyncDelayer asyncDelayer, ILogger< DiscordProvider > logger, IAssemblyInformationProvider assemblyInformationProvider, IOptionsMonitor< GeneralConfiguration > generalConfigurationOptions, ChatBot chatBot, Func< IEnumerable< string > > commandNamesFactory)
Initializes a new instance of the DiscordProvider class.
static string NormalizeMentions(string fromDiscord)
Normalize a discord mention string.
TaskCompletionSource? gatewayReadyTcs
The TaskCompletionSource for the initial gateway connection event.
override async ValueTask Connect(CancellationToken cancellationToken)
const int MaxAutocompleteChoices
The maximum amount of Discord autocomplete choices.
override async ValueTask< Dictionary< Models.ChatChannel, IEnumerable< ChannelRepresentation > > > MapChannelsImpl(IEnumerable< Models.ChatChannel > channels, CancellationToken cancellationToken)
override async ValueTask< Func< string?, string, ValueTask< Func< bool, ValueTask > > > > SendUpdateMessage(Models.RevisionInformation revisionInformation, Models.RevisionInformation? previousRevisionInformation, EngineVersion engineVersion, DateTimeOffset? estimatedCompletionTime, string? gitHubOwner, string? gitHubRepo, ulong channelId, bool localCommitPushed, CancellationToken cancellationToken)
Send the message for a deployment.A ValueTask<TResult> resulting in a Func<T1, T2,...
readonly IAssemblyInformationProvider assemblyInformationProvider
The IAssemblyInformationProvider for the DiscordProvider.
static bool IsTgsSlashCommand(IApplicationCommand command, string configuredCommandName)
Checks if an application command is a registered TGS slash command that should be removed from a stal...
bool messageContentsAvailable
If Discord message contents are available.
async Task< Result > RespondAsync(IMessageCreate messageCreateEvent, CancellationToken cancellationToken)
readonly DiscordDMOutputDisplayType outputDisplayType
The DiscordDMOutputDisplayType.
Task< Result >? gatewayTask
The Task representing the lifetime of the client.
override async ValueTask DisconnectImpl(CancellationToken cancellationToken)
DiscordProvider(IJobManager jobManager, IAsyncDelayer asyncDelayer, ILogger< DiscordProvider > logger, IAssemblyInformationProvider assemblyInformationProvider, IOptionsMonitor< GeneralConfiguration > generalConfigurationOptions, ChatBot chatBot, Func< IEnumerable< string > >? commandNamesFactory, ServiceProvider? serviceProviderOverride)
Initializes a new instance of the DiscordProvider class.
List< IEmbedField > BuildUpdateEmbedFields(Models.RevisionInformation revisionInformation, Models.RevisionInformation? previousRevisionInformation, EngineVersion engineVersion, string? gitHubOwner, string? gitHubRepo, bool localCommitPushed)
Create a List<T> of IEmbedFields for a discord update embed.
async ValueTask RegisterSlashCommand(Snowflake applicationId, CancellationToken cancellationToken)
Registers the configured slash command.
const string SlashCommandOptionName
The Discord slash command text option name.
const string SlashCommandDescription
The description of the TGS slash command.
async ValueTask< IEnumerable< IChannel > > GetAllAccessibleTextChannels(CancellationToken cancellationToken)
Get all text IChannels accessible to and supported by the bot.
void SetMessageContentsAvailable(bool available)
Sets if message contents are available and updates the gateway options.
async Task< Result > RespondAutocomplete(IInteractionCreate interactionCreateEvent, IApplicationCommandData commandData, IDiscordRestInteractionAPI interactionClient, CancellationToken cancellationToken)
Responds to a Discord slash command autocomplete interaction.
static string NormalizeSlashCommandName(string? commandName)
Normalizes a configured Discord slash command name.
override bool Connected
If the IProvider is currently connected.
readonly IOptionsMonitor< GeneralConfiguration > generalConfigurationOptions
The GeneralConfiguration IOptionsMonitor<TOptions> for the DiscordProvider.
readonly string slashCommandName
The Discord slash command name.
Task< Result > RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
readonly ServiceProvider serviceProvider
The ServiceProvider containing Discord services.
readonly Func< IEnumerable< string > > commandNamesFactory
Gets the currently available chat command names.
CancellationTokenSource? gatewayCts
The CancellationTokenSource for the gatewayTask.
override string BotMention
The string that indicates the IProvider was mentioned.
static bool HasMessageContentIntent(ApplicationFlags applicationFlags)
Checks if an application can request message content.
Represents a message received by a IProvider.
Definition Message.cs:9
void EnqueueMessage(Message? message)
Queues a message for NextMessage(CancellationToken).
Definition Provider.cs:239
static string GetEngineCompilerPrefix(Api.Models.EngineType engineType)
Get the prefix for messages about deployments.
readonly IJobManager jobManager
The IJobManager for the Provider.
Definition Provider.cs:40
ILogger< Provider > Logger
The ILogger for the Provider.
Definition Provider.cs:35
Represents an embed for the chat.
Definition ChatEmbed.cs:9
string? Timestamp
The ISO 8601 timestamp of the embed.
Definition ChatEmbed.cs:30
ChatEmbedMedia? Image
The ChatEmbedMedia for an image.
Definition ChatEmbed.cs:45
ChatEmbedFooter? Footer
The ChatEmbedFooter.
Definition ChatEmbed.cs:40
ChatEmbedMedia? Thumbnail
The ChatEmbedMedia for a thumbnail.
Definition ChatEmbed.cs:50
ICollection< ChatEmbedField >? Fields
The ChatEmbedFields.
Definition ChatEmbed.cs:70
string? Colour
The colour of the embed in the format hex "#AARRGGBB".
Definition ChatEmbed.cs:35
ChatEmbedAuthor? Author
The ChatEmbedAuthor.
Definition ChatEmbed.cs:65
string? Url
Gets the source URL of the media. Only supports http(s) and attachments.
Represents a message to send to a chat provider.
const string DefaultDiscordSlashCommandName
The default value for DiscordSlashCommandName.
Operation exceptions thrown from the context of a Models.Job.
Many to many relationship for Models.RevisionInformation and Models.TestMerge.
Combined interface for the IResponder types used by TGS.
Manages the runtime of Jobs.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition ErrorCode.cs:12
EngineType
The type of engine the codebase is using.
Definition EngineType.cs:7
@ Optional
DMAPI validation is performed but not required for the deployment to succeed.
DiscordDMOutputDisplayType
When the DM output section of Discord deployment embeds should be shown.