2using System.Collections.Concurrent;
3using System.Collections.Generic;
5using System.Globalization;
8using System.Threading.Tasks;
10using Microsoft.Extensions.DependencyInjection;
11using Microsoft.Extensions.Logging;
12using Microsoft.Extensions.Options;
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;
44 #pragma warning disable CA1506
56 throw new InvalidOperationException(
"Provider not connected");
88 var normalized = commandName?.Trim();
89 if (String.IsNullOrWhiteSpace(normalized))
92#pragma warning disable CA1308
93 normalized = normalized.ToLowerInvariant();
94#pragma warning restore CA1308
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));
110 ArgumentNullException.ThrowIfNull(command);
111 ArgumentNullException.ThrowIfNull(configuredCommandName);
113 return command.Type == ApplicationCommandType.ChatInput
114 && !command.Name.Equals(configuredCommandName, StringComparison.OrdinalIgnoreCase)
126 var trimmedArguments = arguments?.Trim();
127 return String.IsNullOrWhiteSpace(trimmedArguments)
128 ? $
"{ChatManager.CommonMention} {commandName.Trim()}"
129 : $
"{ChatManager.CommonMention} {commandName.Trim()} {trimmedArguments}";
138 => applicationFlags.HasFlag(ApplicationFlags.GatewayMessageContent)
139 || applicationFlags.HasFlag(ApplicationFlags.GatewayMessageContentLimited);
147 static string?
GetStringOption(IApplicationCommandData commandData,
string optionName)
149 var option = commandData.Options.HasValue
150 ? commandData.Options.Value.FirstOrDefault(option => option.Name.Equals(optionName, StringComparison.Ordinal))
153 if (option ==
null || !option.Value.HasValue || !option.Value.Value.IsT0)
156 return option.Value.Value.AsT0;
164 ChannelType.GuildText,
165 ChannelType.GuildAnnouncement,
166 ChannelType.PrivateThread,
167 ChannelType.PublicThread,
175 new ApplicationCommandOption(
176 ApplicationCommandOptionType.String,
178 "The TGS command name.",
180 EnableAutocomplete: true),
181 new ApplicationCommandOption(
182 ApplicationCommandOptionType.String,
184 "The TGS command arguments."),
267 static string NormalizeMentions(
string fromDiscord) => fromDiscord.Replace(
"<@!",
"<@", StringComparison.Ordinal);
282 ILogger<DiscordProvider> logger,
305 ILogger<DiscordProvider> logger,
310 ServiceProvider? serviceProviderOverride)
311 : base(
jobManager, asyncDelayer, logger, chatBot)
323 var botToken = csb.BotToken!;
331 .BuildServiceProvider();
345 await base.DisposeAsync();
349 Logger.LogTrace(
"ServiceProvider disposed");
360 ArgumentNullException.ThrowIfNull(message);
362 Optional<IMessageReference> replyToReference =
default;
363 Optional<IAllowedMentions> allowedMentions =
default;
366 replyToReference = discordMessage.MessageReference;
367 allowedMentions =
new AllowedMentions(
368 Parse:
new List<MentionType>
370 MentionType.Everyone,
374 MentionRepliedUser:
false);
379 if (replyTo is
DiscordMessage { InteractionToken: { } interactionToken, ApplicationId: { } applicationId })
383 var interactionClient =
serviceProvider.GetRequiredService<IDiscordRestInteractionAPI>();
384 Optional<IReadOnlyList<IEmbed>?> interactionEmbeds = embeds.HasValue
385 ?
new Optional<IReadOnlyList<IEmbed>?>(embeds.Value)
387 Optional<IAllowedMentions?> interactionAllowedMentions = allowedMentions.HasValue
388 ?
new Optional<IAllowedMentions?>(allowedMentions.Value)
390 var content = message.
Text;
394 "Failed to send interaction response to channel {channelId}: Message was null!",
397 content =
"TGS: Could not send message to Discord. Message was `null`!";
400 var response = await interactionClient.EditOriginalInteractionResponseAsync(
404 embeds: interactionEmbeds,
405 allowedMentions: interactionAllowedMentions,
406 ct: cancellationToken);
408 if (!response.IsSuccess)
411 "Failed to send interaction response to channel {channelId}: {result}",
413 response.LogFormat());
415 if (response.Error is RestResultError<RestError> restError && restError.Error.Code == DiscordError.InvalidFormBody)
416 await interactionClient.EditOriginalInteractionResponseAsync(
419 "TGS: Could not send message to Discord. Body was malformed or too long",
420 ct: cancellationToken);
423 catch (
Exception e) when (e is not OperationCanceledException)
427 "Error sending interaction response to channel {channelId}",
434 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
436 async ValueTask SendToChannel(Snowflake channelId)
438 if (message.
Text ==
null)
441 "Failed to send to channel {channelId}: Message was null!",
444 await channelsClient.CreateMessageAsync(
446 "TGS: Could not send message to Discord. Message was `null`!",
447 messageReference: replyToReference,
448 allowedMentions: allowedMentions,
449 ct: cancellationToken);
454 var result = await channelsClient.CreateMessageAsync(
458 messageReference: replyToReference,
459 allowedMentions: allowedMentions,
460 ct: cancellationToken);
462 if (!result.IsSuccess)
465 "Failed to send to channel {channelId}: {result}",
469 if (result.Error is RestResultError<RestError> restError && restError.Error.Code == DiscordError.InvalidFormBody)
470 await channelsClient.CreateMessageAsync(
472 "TGS: Could not send message to Discord. Body was malformed or too long",
473 messageReference: replyToReference,
474 allowedMentions: allowedMentions,
475 ct: cancellationToken);
483 IEnumerable<IChannel> unmappedTextChannels;
487 unmappedTextChannels = allAccessibleTextChannels
491 var remapRequired = unmappedTextChannels.Any()
493 mappedChannel => !allAccessibleTextChannels.Any(
494 accessibleTextChannel => accessibleTextChannel.ID ==
new Snowflake(mappedChannel)));
501 if (unmappedTextChannels.Any())
503 Logger.LogDebug(
"Dispatching to {count} unmapped channels...", unmappedTextChannels.Count());
505 unmappedTextChannels.Select(
506 x => SendToChannel(x.ID)));
512 await SendToChannel(
new Snowflake(channelId));
514 catch (
Exception e) when (e is not OperationCanceledException)
516 Logger.LogWarning(e,
"Error sending discord message!");
521 public override async ValueTask<Func<string?, string, ValueTask<Func<bool, ValueTask>>>>
SendUpdateMessage(
522 Models.RevisionInformation revisionInformation,
523 Models.RevisionInformation? previousRevisionInformation,
525 DateTimeOffset? estimatedCompletionTime,
529 bool localCommitPushed,
530 CancellationToken cancellationToken)
532 ArgumentNullException.ThrowIfNull(revisionInformation);
533 ArgumentNullException.ThrowIfNull(engineVersion);
535 localCommitPushed |= revisionInformation.CommitSha == revisionInformation.OriginCommitSha;
537 var fields =
BuildUpdateEmbedFields(revisionInformation, previousRevisionInformation, engineVersion, gitHubOwner, gitHubRepo, localCommitPushed);
540 Url =
"https://github.com/tgstation/tgstation-server",
541 IconUrl =
"https://cdn.discordapp.com/attachments/1114451486374637629/1151650846019432448/tgs.png",
543 var embed =
new Embed
546 Colour = Color.FromArgb(0xF1, 0xC4, 0x0F),
547 Description =
"TGS has begun deploying active repository code to production.",
549 Title =
"Code Deployment",
550 Footer =
new EmbedFooter(
551 $
"In progress...{(estimatedCompletionTime.HasValue ? " ETA
" : String.Empty)}"),
552 Timestamp = estimatedCompletionTime ??
default,
555 Logger.LogTrace(
"Attempting to post deploy embed to channel {channelId}...", channelId);
556 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
559 var messageResponse = await channelsClient.CreateMessageAsync(
560 new Snowflake(channelId),
561 $
"{prefix}: Deployment in progress...",
562 embeds:
new List<IEmbed> { embed },
563 ct: cancellationToken);
565 if (!messageResponse.IsSuccess)
566 Logger.LogWarning(
"Failed to post deploy embed to channel {channelId}: {result}", channelId, messageResponse.LogFormat());
568 return async (errorMessage, dreamMakerOutput) =>
570 var completionString = errorMessage ==
null ?
"Pending" :
"Failed";
572 Embed CreateUpdatedEmbed(
string message, Color color) =>
new()
574 Author = embed.Author,
576 Description = message,
579 Footer =
new EmbedFooter(
581 Timestamp = DateTimeOffset.UtcNow,
584 if (errorMessage ==
null)
585 embed = CreateUpdatedEmbed(
586 "The deployment completed successfully and will be available at the next server reboot.",
589 embed = CreateUpdatedEmbed(
590 "The deployment failed.",
595 DiscordDMOutputDisplayType.Always =>
true,
596 DiscordDMOutputDisplayType.Never =>
false,
597 DiscordDMOutputDisplayType.OnError => errorMessage !=
null,
598 _ =>
throw new InvalidOperationException($
"Invalid DiscordDMOutputDisplayType: {outputDisplayType}"),
601 if (dreamMakerOutput !=
null)
604 const int MaxFieldValueLength = 1024;
605 showDMOutput = showDMOutput && dreamMakerOutput.Length < MaxFieldValueLength - (6 + Environment.NewLine.Length);
607 fields.Add(
new EmbedField(
609 $
"```{Environment.NewLine}{dreamMakerOutput}{Environment.NewLine}```",
613 if (errorMessage !=
null)
614 fields.Add(
new EmbedField(
619 var updatedMessageText = errorMessage ==
null ? $
"{prefix}: Deployment pending reboot..." : $
"{prefix}: Deployment failed!";
621 IMessage? updatedMessage =
null;
622 async ValueTask CreateUpdatedMessage()
624 var createUpdatedMessageResponse = await channelsClient.CreateMessageAsync(
625 new Snowflake(channelId),
627 embeds:
new List<IEmbed> { embed },
628 ct: cancellationToken);
630 if (!createUpdatedMessageResponse.IsSuccess)
632 "Creating updated deploy embed failed: {result}",
633 createUpdatedMessageResponse.LogFormat());
635 updatedMessage = createUpdatedMessageResponse.Entity;
638 if (!messageResponse.IsSuccess)
639 await CreateUpdatedMessage();
642 var editResponse = await channelsClient.EditMessageAsync(
643 new Snowflake(channelId),
644 messageResponse.Entity.ID,
646 embeds:
new List<IEmbed> { embed },
647 ct: cancellationToken);
649 if (!editResponse.IsSuccess)
652 "Updating deploy embed {messageId} failed, attempting new post: {result}",
653 messageResponse.Entity.ID,
654 editResponse.LogFormat());
655 await CreateUpdatedMessage();
658 updatedMessage = editResponse.Entity;
661 return async (active) =>
663 if (updatedMessage ==
null || errorMessage !=
null)
668 completionString =
"Succeeded";
669 updatedMessageText = $
"{prefix}: Deployment succeeded!";
670 embed = CreateUpdatedEmbed(
671 "The deployment completed successfully and was applied to server.",
676 completionString =
"Inactive";
677 embed = CreateUpdatedEmbed(
678 "This deployment has been superceeded by a new one.",
682 var editResponse = await channelsClient.EditMessageAsync(
683 new Snowflake(channelId),
686 embeds:
new List<IEmbed> { embed },
687 ct: cancellationToken);
689 if (!editResponse.IsSuccess)
691 "Finalizing deploy embed {messageId} failed: {result}",
692 messageResponse.Entity.ID,
693 editResponse.LogFormat());
699 public async Task<Result>
RespondAsync(IMessageCreate messageCreateEvent, CancellationToken cancellationToken)
701 ArgumentNullException.ThrowIfNull(messageCreateEvent);
704 return Result.FromSuccess();
706 if ((messageCreateEvent.Type != MessageType.Default
707 && messageCreateEvent.Type != MessageType.InlineReply)
709 return Result.FromSuccess();
711 var messageReference =
new MessageReference
713 ChannelID = messageCreateEvent.ChannelID,
714 GuildID = messageCreateEvent.GuildID,
715 MessageID = messageCreateEvent.ID,
716 FailIfNotExists =
false,
719 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
720 var channelResponse = await channelsClient.GetChannelAsync(messageCreateEvent.ChannelID, cancellationToken);
721 if (!channelResponse.IsSuccess)
724 "Failed to get channel {channelId} in response to message {messageId}!",
725 messageCreateEvent.ChannelID,
726 messageCreateEvent.ID);
729 return Result.FromSuccess();
732 var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM;
733 var shouldNotAnswer = !pm;
739 var mentionedUs = messageCreateEvent.Mentions.Any(x => x.ID ==
currentUserId)
740 || (!shouldNotAnswer && content.Split(
' ').First().Equals(
ChatManager.
CommonMention, StringComparison.OrdinalIgnoreCase));
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);
752 return Result.FromSuccess();
755 string guildName =
"UNKNOWN";
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;
764 "Failed to get channel {channelID} in response to message {messageID}: {result}",
765 messageCreateEvent.ChannelID,
766 messageCreateEvent.ID,
767 messageGuildResponse.LogFormat());
773 pm ? messageCreateEvent.Author.Username : guildName,
774 channelResponse.Entity.Name.Value!,
775 messageCreateEvent.ChannelID.Value)
777 IsPrivateChannel = pm,
778 EmbedsSupported =
true,
782 messageCreateEvent.Author.Username,
784 messageCreateEvent.Author.ID.Value),
789 return Result.FromSuccess();
793 public async Task<Result>
RespondAsync(IInteractionCreate interactionCreateEvent, CancellationToken cancellationToken)
795 ArgumentNullException.ThrowIfNull(interactionCreateEvent);
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();
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,
812 ct: cancellationToken);
814 if (interactionCreateEvent.Type == InteractionType.ApplicationCommandAutocomplete)
815 return await
RespondAutocomplete(interactionCreateEvent, commandData, interactionClient, cancellationToken);
817 var deferResponse = await interactionClient.CreateInteractionResponseAsync(
818 interactionCreateEvent.ID,
819 interactionCreateEvent.Token,
820 new InteractionResponse(InteractionCallbackType.DeferredChannelMessageWithSource),
821 ct: cancellationToken);
822 if (!deferResponse.IsSuccess)
825 "Failed to defer slash command response {interactionId}: {result}",
826 interactionCreateEvent.ID,
827 deferResponse.LogFormat());
828 return Result.FromSuccess();
832 if (String.IsNullOrWhiteSpace(commandText))
834 await RespondWithError(
"TGS: No command text supplied.");
835 return Result.FromSuccess();
840 if (!interactionCreateEvent.Channel.HasValue || !interactionCreateEvent.Channel.Value.ID.HasValue)
842 await RespondWithError(
"TGS: Processing error, check logs!");
843 return Result.FromSuccess();
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)
852 "Failed to get channel {channelId} in response to interaction {interactionId}: {result}",
854 interactionCreateEvent.ID,
855 channelResponse.LogFormat());
857 await RespondWithError(
"TGS: Processing error, check logs!");
858 return Result.FromSuccess();
861 var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM;
862 var shouldNotAnswer = !pm;
869 await RespondWithError(
"TGS: This channel is not mapped.");
870 return Result.FromSuccess();
873 string guildName =
"UNKNOWN";
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;
882 "Failed to get guild {guildId} in response to interaction {interactionId}: {result}",
883 interactionCreateEvent.GuildID.Value,
884 interactionCreateEvent.ID,
885 guildResponse.LogFormat());
888 var user = interactionCreateEvent.Member.HasValue && interactionCreateEvent.Member.Value.User.HasValue
889 ? interactionCreateEvent.Member.Value.User.Value
890 : interactionCreateEvent.User.Value;
894 pm ? user.Username : guildName,
895 channelResponse.Entity.Name.Value!,
898 IsPrivateChannel = pm,
899 EmbedsSupported =
true,
906 interactionCreateEvent.ApplicationID,
907 interactionCreateEvent.Token);
910 return Result.FromSuccess();
914 public Task<Result>
RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
916 ArgumentNullException.ThrowIfNull(readyEvent);
918 Logger.LogTrace(
"Gatway ready. Version: {version}", readyEvent.Version);
920 return Task.FromResult(Result.FromSuccess());
924 protected override async ValueTask
Connect(CancellationToken cancellationToken)
931 throw new InvalidOperationException(
"Discord gateway still active!");
936 var gatewayCancellationToken =
gatewayCts.Token;
937 Task<Result>? localGatewayTask =
null;
938 using var localCombinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, gatewayCancellationToken);
939 var localCombinedCancellationToken = localCombinedCts.Token;
943 var applicationClient =
serviceProvider.GetRequiredService<IDiscordRestApplicationAPI>();
944 var currentApplicationResponse = await applicationClient.GetCurrentApplicationAsync(localCombinedCancellationToken);
945 if (currentApplicationResponse.IsSuccess)
947 var application = currentApplicationResponse.Entity;
953 localCombinedCancellationToken.ThrowIfCancellationRequested();
955 "Unable to retrieve current Discord application. Retrying connection: {result}",
956 currentApplicationResponse.LogFormat());
959 new InvalidOperationException($
"Discord application request failed: {currentApplicationResponse.LogFormat()}"));
962 var gatewayClient =
serviceProvider.GetRequiredService<DiscordGatewayClient>();
966 using var gatewayConnectionAbortRegistration = cancellationToken.Register(() =>
gatewayReadyTcs.TrySetCanceled(cancellationToken));
967 gatewayCancellationToken.Register(() =>
Logger.LogTrace(
"Stopping gateway client..."));
970 localGatewayTask = gatewayClient.RunAsync(gatewayCancellationToken);
973 cancellationToken.ThrowIfCancellationRequested();
974 if (localGatewayTask.IsCompleted)
976 var gatewayResult = await localGatewayTask;
977 Logger.LogWarning(
"Discord gateway stopped before becoming ready: {result}", gatewayResult.LogFormat());
980 new InvalidOperationException($
"Discord gateway failed: {gatewayResult.LogFormat()}"));
983 var userClient =
serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
984 var currentUserResult = await userClient.GetCurrentUserAsync(localCombinedCancellationToken);
985 if (!currentUserResult.IsSuccess)
987 localCombinedCancellationToken.ThrowIfCancellationRequested();
988 Logger.LogWarning(
"Unable to retrieve current user: {result}", currentUserResult.LogFormat());
991 new InvalidOperationException($
"Discord current-user request failed: {currentUserResult.LogFormat()}"));
1011 protected override async ValueTask
DisconnectImpl(CancellationToken cancellationToken)
1013 Task<Result> localGatewayTask;
1014 CancellationTokenSource localGatewayCts;
1021 if (localGatewayTask ==
null)
1023 localGatewayCts?.Dispose();
1028 localGatewayCts.Cancel();
1029 var gatewayResult = await localGatewayTask;
1031 Logger.LogTrace(
"Gateway task complete");
1032 if (!gatewayResult.IsSuccess)
1033 Logger.LogWarning(
"Gateway issue: {result}", gatewayResult.LogFormat());
1035 localGatewayCts.Dispose();
1039 protected override async ValueTask<Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>
MapChannelsImpl(IEnumerable<Models.ChatChannel> channels, CancellationToken cancellationToken)
1041 ArgumentNullException.ThrowIfNull(channels);
1043 var remapRequired =
false;
1044 var guildsClient =
serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
1045 var guildTasks =
new ConcurrentDictionary<Snowflake, Task<Result<IGuild>>>();
1047 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>?> GetModelChannelFromDBChannel(Models.ChatChannel channelFromDB)
1049 if (!channelFromDB.DiscordChannelId.HasValue)
1050 throw new InvalidOperationException(
"ChatChannel missing DiscordChannelId!");
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)
1058 "Error retrieving discord channel {channelId}: {result}",
1060 discordChannelResponse.LogFormat());
1062 var remapConditional = !(discordChannelResponse.Error is RestResultError<RestError> restResultError
1063 && (restResultError.Error?.Code == DiscordError.MissingAccess
1064 || restResultError.Error?.Code == DiscordError.UnknownChannel));
1066 if (remapConditional)
1072 "Error on channel {channelId} is not an access/thread issue. Will retry remap...",
1074 remapRequired =
true;
1080 var channelType = discordChannelResponse.Entity.Type;
1083 Logger.LogWarning(
"Cound not map channel {channelId}! Incorrect type: {channelType}", channelId, discordChannelResponse.Entity.Type);
1087 var guildId = discordChannelResponse.Entity.GuildID.Value;
1090 var guildsResponse = await guildTasks.GetOrAdd(
1095 return guildsClient.GetGuildAsync(
1100 if (!guildsResponse.IsSuccess)
1105 "Error retrieving discord guild {guildID}: {result}",
1107 guildsResponse.LogFormat());
1108 remapRequired =
true;
1114 var connectionName = guildsResponse.Entity.Name;
1117 guildsResponse.Entity.Name,
1118 discordChannelResponse.Entity.Name.Value!,
1121 IsAdminChannel = channelFromDB.IsAdminChannel ==
true,
1122 IsPrivateChannel =
false,
1123 Tag = channelFromDB.
Tag,
1124 EmbedsSupported =
true,
1127 Logger.LogTrace(
"Mapped channel {realId}: {friendlyName}", channelModel.RealId, channelModel.FriendlyName);
1128 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
1130 new List<ChannelRepresentation> { channelModel });
1133 var tasks = channels
1134 .Where(x => x.DiscordChannelId != 0)
1135 .Select(GetModelChannelFromDBChannel);
1139 var list = channelTuples
1140 .Where(x => x !=
null)
1141 .Cast<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>()
1144 var channelIdZeroModel = channels.FirstOrDefault(x => x.DiscordChannelId == 0);
1145 if (channelIdZeroModel !=
null)
1147 Logger.LogInformation(
"Mapping ALL additional accessible text channels");
1149 var unmappedTextChannels = allAccessibleChannels
1150 .Where(x => !tasks.Any(task => task.Result !=
null &&
new Snowflake(task.Result.Item1.DiscordChannelId!.Value) == x.ID));
1152 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> CreateMappingsForUnmappedChannels()
1155 unmappedTextChannels.Select(
1156 async unmappedTextChannel =>
1160 DiscordChannelId = unmappedTextChannel.ID.Value,
1162 Tag = channelIdZeroModel.Tag,
1165 var tuple = await GetModelChannelFromDBChannel(fakeChannelModel);
1166 return tuple?.Item2.First();
1173 "(Unknown Discord Guilds)",
1174 "(Unknown Discord Channels)",
1178 EmbedsSupported =
true,
1179 Tag = channelIdZeroModel.Tag,
1182 await Task.WhenAll(unmappedTasks);
1183 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
1186 .Select(x => x.Result)
1187 .Where(x => x !=
null)
1192 var task = CreateMappingsForUnmappedChannels();
1193 var tuple = await task;
1200 mappedChannels.AddRange(list.SelectMany(x => x.Item2).Select(x => x.RealId));
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.");
1209 return new Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(list.Select(x =>
new KeyValuePair<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(x.Item1, x.Item2)));
1219 var gatewayOptions =
serviceProvider.GetRequiredService<IOptions<DiscordGatewayClientOptions>>().Value;
1221 gatewayOptions.Intents |= GatewayIntents.MessageContents;
1223 gatewayOptions.Intents &= ~GatewayIntents.MessageContents;
1233 var usersClient =
serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
1234 var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken);
1235 if (!currentGuildsResponse.IsSuccess)
1238 "Error retrieving current discord guilds: {result}",
1239 currentGuildsResponse.LogFormat());
1240 return Enumerable.Empty<IChannel>();
1243 var guildsClient =
serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
1245 async ValueTask<IEnumerable<IChannel>> GetGuildChannels(IPartialGuild guild)
1247 var channelsTask = guildsClient.GetGuildChannelsAsync(guild.ID.Value, cancellationToken);
1248 var threads = await guildsClient.ListActiveGuildThreadsAsync(guild.ID.Value, cancellationToken);
1249 if (!threads.IsSuccess)
1251 "Error retrieving discord guild threads {guildId} ({guildName}): {result}",
1254 threads.LogFormat());
1256 var channels = await channelsTask;
1257 if (!channels.IsSuccess)
1259 "Error retrieving discord guild channels {guildId} ({guildName}): {result}",
1262 channels.LogFormat());
1264 if (!channels.IsSuccess && !threads.IsSuccess)
1265 return Enumerable.Empty<IChannel>();
1267 if (channels.IsSuccess && threads.IsSuccess)
1268 return channels.Entity.Concat(threads.Entity.Threads ?? Enumerable.Empty<IChannel>());
1270 return channels.Entity ?? threads.Entity?.Threads ?? Enumerable.Empty<IChannel>();
1273 var guildsChannelsTasks = currentGuildsResponse.Entity
1274 .Select(GetGuildChannels);
1278 var allAccessibleChannels = guildsChannels
1279 .SelectMany(channels => channels)
1282 return allAccessibleChannels;
1294 IInteractionCreate interactionCreateEvent,
1295 IApplicationCommandData commandData,
1296 IDiscordRestInteractionAPI interactionClient,
1297 CancellationToken cancellationToken)
1299 var focusedCommand = commandData.Options.HasValue
1300 ? commandData.Options.Value.FirstOrDefault(option => option.Name.Equals(
SlashCommandOptionName, StringComparison.Ordinal) && option.IsFocused.HasValue && option.IsFocused.Value)
1302 var focusedValue = focusedCommand !=
null && focusedCommand.Value.HasValue && focusedCommand.Value.Value.IsT0
1303 ? focusedCommand.Value.Value.AsT0
1307 .Where(command => String.IsNullOrEmpty(focusedValue) || command.StartsWith(focusedValue, StringComparison.OrdinalIgnoreCase))
1309 .Select(command => (IApplicationCommandOptionChoice)
new ApplicationCommandOptionChoice(command, OneOf<string, int, double>.FromT0(command)))
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);
1322 if (!response.IsSuccess)
1324 "Failed to respond to slash command autocomplete {interactionId}: {result}",
1325 interactionCreateEvent.ID,
1326 response.LogFormat());
1328 return Result.FromSuccess();
1339 var applicationClient =
serviceProvider.GetRequiredService<IDiscordRestApplicationAPI>();
1340 var updateResponse = await applicationClient.CreateGlobalApplicationCommandAsync(
1345 ct: cancellationToken);
1347 if (!updateResponse.IsSuccess)
1349 cancellationToken.ThrowIfCancellationRequested();
1350 Logger.LogWarning(
"Unable to register Discord slash command: {result}", updateResponse.LogFormat());
1353 new InvalidOperationException($
"Discord slash-command registration failed: {updateResponse.LogFormat()}"));
1358 var globalCommandsResponse = await applicationClient.GetGlobalApplicationCommandsAsync(applicationId, ct: cancellationToken);
1359 if (globalCommandsResponse.IsSuccess)
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());
1366 Logger.LogInformation(
"Removed stale global Discord slash command /{commandName}.", command.Name);
1369 Logger.LogWarning(
"Unable to inspect global Discord slash commands for stale entries: {result}", globalCommandsResponse.LogFormat());
1383 Models.RevisionInformation revisionInformation,
1384 Models.RevisionInformation? previousRevisionInformation,
1386 string? gitHubOwner,
1388 bool localCommitPushed)
1390 bool gitHub = gitHubOwner !=
null && gitHubRepo !=
null;
1391 var engineField = engineVersion.
Engine!.Value
switch
1393 EngineType.Byond =>
new EmbedField(
1395 $
"{engineVersion.Version!.Major}.{engineVersion.Version.Minor}{(engineVersion.CustomIteration.HasValue ? $".{engineVersion.
CustomIteration.Value}
" : String.Empty)}",
1398 "OpenDream Version",
1399 $
"[{engineVersion.SourceSHA![..7]}]({generalConfigurationOptions.CurrentValue.OpenDreamGitUrl}/commit/{engineVersion.SourceSHA})",
1401 _ =>
throw new InvalidOperationException($
"Invaild EngineType: {engineVersion.Engine.Value}"),
1404 var revisionSha = revisionInformation.CommitSha!;
1405 var revisionOriginSha = revisionInformation.OriginCommitSha!;
1406 var fields =
new List<IEmbedField>
1411 if (gitHubOwner ==
null || gitHubRepo ==
null)
1414 var previousTestMerges = (IEnumerable<RevInfoTestMerge>?)previousRevisionInformation?.ActiveTestMerges ?? Enumerable.Empty<
RevInfoTestMerge>();
1415 var currentTestMerges = (IEnumerable<RevInfoTestMerge>?)revisionInformation.ActiveTestMerges ?? Enumerable.Empty<
RevInfoTestMerge>();
1418 var addedTestMerges = currentTestMerges
1419 .Select(x => x.TestMerge)
1420 .Where(x => !previousTestMerges
1421 .Any(y => y.TestMerge.Number == x.Number))
1423 var removedTestMerges = previousTestMerges
1424 .Select(x => x.TestMerge)
1425 .Where(x => !currentTestMerges
1426 .Any(y => y.TestMerge.Number == x.Number))
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))
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))
1442 localCommitPushed && gitHub
1443 ? $
"[{revisionSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionSha})"
1451 ? $
"[{revisionOriginSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionOriginSha})"
1452 : revisionOriginSha[..7],
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}**_
")}",
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}**_
")}",
1467 fields.AddRange(unchangedTestMerges
1468 .Select(x =>
new EmbedField(
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}**_
")}",
1473 if (removedTestMerges.Count != 0)
1478 Environment.NewLine,
1480 .Select(x => $
"- #{x.Number} [{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_"))));
1490#pragma warning disable CA1502
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);
1504 CultureInfo.InvariantCulture,
1505 "Invalid embed colour: {0}",
1510 embedErrors.Add(
"Null or whitespace embed author name!");
1511 embed.Author =
null;
1514 List<IEmbedField>? fields =
null;
1515 if (embed.
Fields !=
null)
1517 fields =
new List<IEmbedField>();
1519 foreach (var field
in embed.
Fields)
1522 var invalid =
false;
1523 if (String.IsNullOrWhiteSpace(field.Name))
1527 CultureInfo.InvariantCulture,
1528 "Null or whitespace field name at index {0}!",
1533 if (String.IsNullOrWhiteSpace(field.Value))
1537 CultureInfo.InvariantCulture,
1538 "Null or whitespace field value at index {0}!",
1546 fields.Add(
new EmbedField(field.Name!, field.Value!)
1548 IsInline = field.IsInline ?? default(Optional<bool>),
1555 embedErrors.Add(
"Null or whitespace embed footer text!");
1556 embed.Footer =
null;
1559 if (embed.
Image !=
null && String.IsNullOrWhiteSpace(embed.
Image.
Url))
1561 embedErrors.Add(
"Null or whitespace embed image url!");
1567 embedErrors.Add(
"Null or whitespace embed thumbnail url!");
1568 embed.Thumbnail =
null;
1571 Optional<DateTimeOffset> timestampOptional =
default;
1573 if (DateTimeOffset.TryParse(embed.
Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var timestamp))
1574 timestampOptional = timestamp.ToUniversalTime();
1578 CultureInfo.InvariantCulture,
1579 "Invalid embed timestamp: {0}",
1582 var discordEmbed =
new Embed
1584 Author = embed.Author !=
null
1587 IconUrl = embed.Author.IconUrl ??
default(Optional<string>),
1588 ProxyIconUrl = embed.Author.ProxyIconUrl ??
default(Optional<string>),
1589 Url = embed.Author.Url ??
default(Optional<string>),
1591 :
default(Optional<IEmbedAuthor>),
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!)
1598 IconUrl = embed.Footer.IconUrl ??
default(Optional<string>),
1599 ProxyIconUrl = embed.Footer.ProxyIconUrl ??
default(Optional<string>),
1602 Image = embed.Image !=
null
1605 Width = embed.Image.Width ??
default(Optional<int>),
1606 Height = embed.Image.Height ??
default(Optional<int>),
1607 ProxyUrl = embed.Image.ProxyUrl ??
default(Optional<string>),
1609 :
default(Optional<IEmbedImage>),
1610 Provider = embed.Provider !=
null
1613 Name = embed.Provider.Name ??
default(Optional<string>),
1614 Url = embed.Provider.Url ??
default(Optional<string>),
1616 :
default(Optional<IEmbedProvider>),
1617 Thumbnail = embed.Thumbnail !=
null
1620 Width = embed.Thumbnail.Width ??
default(Optional<int>),
1621 Height = embed.Thumbnail.Height ??
default(Optional<int>),
1622 ProxyUrl = embed.Thumbnail.ProxyUrl ??
default(Optional<string>),
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
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>),
1636 :
default(Optional<IEmbedVideo>),
1639 var result =
new List<IEmbed> { discordEmbed };
1641 if (embedErrors.Count > 0)
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
1647 Title =
"TGS Embed Errors",
1648 Description = joinedErrors,
1650 Footer =
new EmbedFooter(
"Please report this to your codebase's maintainers."),
1651 Timestamp = DateTimeOffset.UtcNow,
1657 #pragma warning restore CA1502
1659#pragma warning restore CA1506
Optional< IReadOnlyList< IEmbed > > ConvertEmbed(ChatEmbed? embed)
Convert a ChatEmbed to an IEmbed parameters.
ChatConnectionStringBuilder for ChatProvider.Discord.
Information about an engine installation.
EngineType? Engine
The EngineType.
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 .
Represents a Providers.IProvider channel.
bool IsAdminChannel
If this is considered a channel for admin commands.
const string CommonMention
The common bot mention.
Represents a tgs_chat_user datum.
An IResponder<TGatewayEvent> that forwards to another targetResponder.
A Message containing the source IMessageReference.
IProvider for the Discord app.
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.
bool disposing
If serviceProvider is being disposed.
Snowflake currentUserId
The bot's Snowflake.
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.
override async ValueTask DisposeAsync()
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.
void EnqueueMessage(Message? message)
Queues a message for NextMessage(CancellationToken).
static string GetEngineCompilerPrefix(Api.Models.EngineType engineType)
Get the prefix for messages about deployments.
readonly IJobManager jobManager
The IJobManager for the Provider.
ILogger< Provider > Logger
The ILogger for the Provider.
Represents an embed for the chat.
string? Timestamp
The ISO 8601 timestamp of the embed.
ChatEmbedMedia? Image
The ChatEmbedMedia for an image.
ChatEmbedFooter? Footer
The ChatEmbedFooter.
ChatEmbedMedia? Thumbnail
The ChatEmbedMedia for a thumbnail.
ICollection< ChatEmbedField >? Fields
The ChatEmbedFields.
string? Colour
The colour of the embed in the format hex "#AARRGGBB".
ChatEmbedAuthor? Author
The ChatEmbedAuthor.
string? Name
Gets the name of the provider.
Represents a message to send to a chat provider.
ChatEmbed? Embed
The ChatEmbed.
string? Text
The message string.
General configuration options.
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.
For waiting asynchronously.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
EngineType
The type of engine the codebase is using.
@ 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.