2using System.Collections.Concurrent;
3using System.Collections.Generic;
5using System.Globalization;
8using System.Threading.Tasks;
10using Microsoft.Extensions.DependencyInjection;
11using Microsoft.Extensions.Logging;
13using Remora.Discord.API.Abstractions.Gateway.Commands;
14using Remora.Discord.API.Abstractions.Gateway.Events;
15using Remora.Discord.API.Abstractions.Objects;
16using Remora.Discord.API.Abstractions.Rest;
17using Remora.Discord.API.Abstractions.Results;
18using Remora.Discord.API.Objects;
19using Remora.Discord.Gateway;
20using Remora.Discord.Gateway.Extensions;
21using Remora.Rest.Core;
22using Remora.Rest.Results;
41 #pragma warning disable CA1506
42 sealed class DiscordProvider : Provider, IDiscordResponders
45 public override bool Connected => gatewayTask?.IsCompleted ==
false;
48 public override string BotMention
53 throw new InvalidOperationException(
"Provider not connected");
54 return NormalizeMentions($
"<@{currentUserId}>");
61 static readonly ChannelType[] SupportedGuildChannelTypes =
63 ChannelType.GuildText,
64 ChannelType.GuildAnnouncement,
65 ChannelType.PrivateThread,
66 ChannelType.PublicThread,
82 readonly ServiceProvider serviceProvider;
87 readonly List<ulong> mappedChannels;
92 readonly
object connectDisconnectLock;
97 readonly
bool deploymentBranding;
107 CancellationTokenSource? gatewayCts;
112 TaskCompletionSource? gatewayReadyTcs;
117 Task<Result>? gatewayTask;
122 Snowflake currentUserId;
134 static string NormalizeMentions(
string fromDiscord) => fromDiscord.Replace(
"<@!",
"<@", StringComparison.Ordinal);
145 public DiscordProvider(
148 ILogger<DiscordProvider> logger,
152 : base(jobManager, asyncDelayer, logger, chatBot)
154 this.assemblyInformationProvider = assemblyInformationProvider ??
throw new ArgumentNullException(nameof(assemblyInformationProvider));
155 this.generalConfiguration = generalConfiguration ??
throw new ArgumentNullException(nameof(generalConfiguration));
157 mappedChannels =
new List<ulong>();
158 connectDisconnectLock =
new object();
161 var botToken = csb.BotToken!;
162 outputDisplayType = csb.DMOutputDisplay;
163 deploymentBranding = csb.DeploymentBranding;
165 serviceProvider =
new ServiceCollection()
166 .AddDiscordGateway(serviceProvider => botToken)
167 .Configure<DiscordGatewayClientOptions>(options => options.Intents |= GatewayIntents.MessageContents)
168 .AddSingleton(serviceProvider => (IDiscordResponders)
this)
169 .AddResponder<DiscordForwardingResponder>()
170 .BuildServiceProvider();
174 public override async ValueTask DisposeAsync()
176 lock (serviceProvider)
184 await base.DisposeAsync();
186 await serviceProvider.DisposeAsync();
188 Logger.LogTrace(
"ServiceProvider disposed");
191 gatewayCts?.Dispose();
197 public override async ValueTask SendMessage(Message? replyTo,
MessageContent message, ulong channelId, CancellationToken cancellationToken)
199 ArgumentNullException.ThrowIfNull(message);
201 Optional<IMessageReference> replyToReference =
default;
202 Optional<IAllowedMentions> allowedMentions =
default;
203 if (replyTo !=
null && replyTo is DiscordMessage discordMessage)
205 replyToReference = discordMessage.MessageReference;
206 allowedMentions =
new AllowedMentions(
207 Parse: new
List<MentionType>
209 MentionType.Everyone,
213 MentionRepliedUser:
false);
216 var embeds = ConvertEmbed(message.
Embed);
218 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
219 async ValueTask SendToChannel(Snowflake channelId)
221 if (message.
Text ==
null)
224 "Failed to send to channel {channelId}: Message was null!",
227 await channelsClient.CreateMessageAsync(
229 "TGS: Could not send message to Discord. Message was `null`!",
230 messageReference: replyToReference,
231 allowedMentions: allowedMentions,
232 ct: cancellationToken);
237 var result = await channelsClient.CreateMessageAsync(
241 messageReference: replyToReference,
242 allowedMentions: allowedMentions,
243 ct: cancellationToken);
245 if (!result.IsSuccess)
248 "Failed to send to channel {channelId}: {result}",
252 if (result.Error is RestResultError<RestError> restError && restError.Error.Code == DiscordError.InvalidFormBody)
253 await channelsClient.CreateMessageAsync(
255 "TGS: Could not send message to Discord. Body was malformed or too long",
256 messageReference: replyToReference,
257 allowedMentions: allowedMentions,
258 ct: cancellationToken);
266 IEnumerable<IChannel> unmappedTextChannels;
267 var allAccessibleTextChannels = await GetAllAccessibleTextChannels(cancellationToken);
268 lock (mappedChannels)
270 unmappedTextChannels = allAccessibleTextChannels
271 .Where(x => !mappedChannels.Contains(x.ID.Value))
274 var remapRequired = unmappedTextChannels.Any()
275 || mappedChannels.Any(
276 mappedChannel => !allAccessibleTextChannels.Any(
277 accessibleTextChannel => accessibleTextChannel.ID ==
new Snowflake(mappedChannel)));
280 EnqueueMessage(
null);
284 if (unmappedTextChannels.Any())
286 Logger.LogDebug(
"Dispatching to {count} unmapped channels...", unmappedTextChannels.Count());
288 unmappedTextChannels.Select(
289 x => SendToChannel(x.ID)));
295 await SendToChannel(
new Snowflake(channelId));
297 catch (
Exception e) when (e is not OperationCanceledException)
299 Logger.LogWarning(e,
"Error sending discord message!");
304 public override async ValueTask<Func<string?, string, ValueTask<Func<bool, ValueTask>>>> SendUpdateMessage(
305 Models.RevisionInformation revisionInformation,
307 DateTimeOffset? estimatedCompletionTime,
311 bool localCommitPushed,
312 CancellationToken cancellationToken)
314 ArgumentNullException.ThrowIfNull(revisionInformation);
315 ArgumentNullException.ThrowIfNull(engineVersion);
317 localCommitPushed |= revisionInformation.CommitSha == revisionInformation.OriginCommitSha;
319 var fields = BuildUpdateEmbedFields(revisionInformation, engineVersion, gitHubOwner, gitHubRepo, localCommitPushed);
320 Optional<IEmbedAuthor> author =
new EmbedAuthor(assemblyInformationProvider.
VersionPrefix)
322 Url =
"https://github.com/tgstation/tgstation-server",
323 IconUrl =
"https://cdn.discordapp.com/attachments/1114451486374637629/1151650846019432448/tgs.png",
325 var embed =
new Embed
327 Author = deploymentBranding ? author :
default,
328 Colour = Color.FromArgb(0xF1, 0xC4, 0x0F),
329 Description =
"TGS has begun deploying active repository code to production.",
331 Title =
"Code Deployment",
332 Footer =
new EmbedFooter(
333 $
"In progress...{(estimatedCompletionTime.HasValue ? " ETA
" : String.Empty)}"),
334 Timestamp = estimatedCompletionTime ??
default,
337 Logger.LogTrace(
"Attempting to post deploy embed to channel {channelId}...", channelId);
338 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
340 var prefix = GetEngineCompilerPrefix(engineVersion.
Engine!.Value);
341 var messageResponse = await channelsClient.CreateMessageAsync(
342 new Snowflake(channelId),
343 $
"{prefix}: Deployment in progress...",
344 embeds: new
List<IEmbed> { embed },
345 ct: cancellationToken);
347 if (!messageResponse.IsSuccess)
348 Logger.LogWarning(
"Failed to post deploy embed to channel {channelId}: {result}", channelId, messageResponse.LogFormat());
350 return async (errorMessage, dreamMakerOutput) =>
352 var completionString = errorMessage ==
null ?
"Pending" :
"Failed";
354 Embed CreateUpdatedEmbed(
string message, Color color) =>
new()
356 Author = embed.Author,
358 Description = message,
361 Footer =
new EmbedFooter(
363 Timestamp = DateTimeOffset.UtcNow,
366 if (errorMessage ==
null)
367 embed = CreateUpdatedEmbed(
368 "The deployment completed successfully and will be available at the next server reboot.",
371 embed = CreateUpdatedEmbed(
372 "The deployment failed.",
375 var showDMOutput = outputDisplayType
switch
377 DiscordDMOutputDisplayType.Always =>
true,
378 DiscordDMOutputDisplayType.Never =>
false,
379 DiscordDMOutputDisplayType.OnError => errorMessage !=
null,
380 _ =>
throw new InvalidOperationException($
"Invalid DiscordDMOutputDisplayType: {outputDisplayType}"),
383 if (dreamMakerOutput !=
null)
386 const int MaxFieldValueLength = 1024;
387 showDMOutput = showDMOutput && dreamMakerOutput.Length < MaxFieldValueLength - (6 + Environment.NewLine.Length);
389 fields.Add(
new EmbedField(
391 $
"```{Environment.NewLine}{dreamMakerOutput}{Environment.NewLine}```",
395 if (errorMessage !=
null)
396 fields.Add(
new EmbedField(
401 var updatedMessageText = errorMessage ==
null ? $
"{prefix}: Deployment pending reboot..." : $
"{prefix}: Deployment failed!";
403 IMessage? updatedMessage =
null;
404 async ValueTask CreateUpdatedMessage()
406 var createUpdatedMessageResponse = await channelsClient.CreateMessageAsync(
407 new Snowflake(channelId),
409 embeds: new
List<IEmbed> { embed },
410 ct: cancellationToken);
412 if (!createUpdatedMessageResponse.IsSuccess)
414 "Creating updated deploy embed failed: {result}",
415 createUpdatedMessageResponse.LogFormat());
417 updatedMessage = createUpdatedMessageResponse.Entity;
420 if (!messageResponse.IsSuccess)
421 await CreateUpdatedMessage();
424 var editResponse = await channelsClient.EditMessageAsync(
425 new Snowflake(channelId),
426 messageResponse.Entity.ID,
428 embeds: new
List<IEmbed> { embed },
429 ct: cancellationToken);
431 if (!editResponse.IsSuccess)
434 "Updating deploy embed {messageId} failed, attempting new post: {result}",
435 messageResponse.Entity.ID,
436 editResponse.LogFormat());
437 await CreateUpdatedMessage();
440 updatedMessage = editResponse.Entity;
443 return async (active) =>
445 if (updatedMessage ==
null || errorMessage !=
null)
450 completionString =
"Succeeded";
451 updatedMessageText = $
"{prefix}: Deployment succeeded!";
452 embed = CreateUpdatedEmbed(
453 "The deployment completed successfully and was applied to server.",
458 completionString =
"Inactive";
459 embed = CreateUpdatedEmbed(
460 "This deployment has been superceeded by a new one.",
464 var editResponse = await channelsClient.EditMessageAsync(
465 new Snowflake(channelId),
468 embeds: new
List<IEmbed> { embed },
469 ct: cancellationToken);
471 if (!editResponse.IsSuccess)
473 "Finalizing deploy embed {messageId} failed: {result}",
474 messageResponse.Entity.ID,
475 editResponse.LogFormat());
481 public async Task<Result> RespondAsync(IMessageCreate messageCreateEvent, CancellationToken cancellationToken)
483 ArgumentNullException.ThrowIfNull(messageCreateEvent);
485 if ((messageCreateEvent.Type != MessageType.Default
486 && messageCreateEvent.Type != MessageType.InlineReply)
487 || messageCreateEvent.Author.ID == currentUserId)
488 return Result.FromSuccess();
490 var messageReference =
new MessageReference
492 ChannelID = messageCreateEvent.ChannelID,
493 GuildID = messageCreateEvent.GuildID,
494 MessageID = messageCreateEvent.ID,
495 FailIfNotExists =
false,
498 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
499 var channelResponse = await channelsClient.GetChannelAsync(messageCreateEvent.ChannelID, cancellationToken);
500 if (!channelResponse.IsSuccess)
503 "Failed to get channel {channelId} in response to message {messageId}!",
504 messageCreateEvent.ChannelID,
505 messageCreateEvent.ID);
508 return Result.FromSuccess();
511 var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM;
512 var shouldNotAnswer = !pm;
514 lock (mappedChannels)
515 shouldNotAnswer = !mappedChannels.Contains(messageCreateEvent.ChannelID.Value) && !mappedChannels.Contains(0);
517 var content = NormalizeMentions(messageCreateEvent.Content);
518 var mentionedUs = messageCreateEvent.Mentions.Any(x => x.ID == currentUserId)
519 || (!shouldNotAnswer && content.Split(
' ').First().Equals(ChatManager.CommonMention, StringComparison.OrdinalIgnoreCase));
525 "Ignoring mention from {channelId} ({channelName}) by {authorId} ({authorName}). Channel not mapped!",
526 messageCreateEvent.ChannelID,
527 channelResponse.Entity.Name,
528 messageCreateEvent.Author.ID,
529 messageCreateEvent.Author.Username);
531 return Result.FromSuccess();
534 string guildName =
"UNKNOWN";
537 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
538 var messageGuildResponse = await guildsClient.GetGuildAsync(messageCreateEvent.GuildID.Value,
false, cancellationToken);
539 if (messageGuildResponse.IsSuccess)
540 guildName = messageGuildResponse.Entity.Name;
543 "Failed to get channel {channelID} in response to message {messageID}: {result}",
544 messageCreateEvent.ChannelID,
545 messageCreateEvent.ID,
546 messageGuildResponse.LogFormat());
549 var result =
new DiscordMessage(
551 new ChannelRepresentation(
552 pm ? messageCreateEvent.Author.Username : guildName,
553 channelResponse.Entity.Name.Value!,
554 messageCreateEvent.ChannelID.Value)
556 IsPrivateChannel = pm,
557 EmbedsSupported =
true,
561 messageCreateEvent.Author.Username,
562 NormalizeMentions($
"<@{messageCreateEvent.Author.ID}>"),
563 messageCreateEvent.Author.ID.Value),
567 EnqueueMessage(result);
568 return Result.FromSuccess();
572 public Task<Result> RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
574 ArgumentNullException.ThrowIfNull(readyEvent);
576 Logger.LogTrace(
"Gatway ready. Version: {version}", readyEvent.Version);
577 gatewayReadyTcs?.TrySetResult();
578 return Task.FromResult(Result.FromSuccess());
582 protected override async ValueTask Connect(CancellationToken cancellationToken)
586 lock (connectDisconnectLock)
588 if (gatewayCts !=
null)
589 throw new InvalidOperationException(
"Discord gateway still active!");
591 gatewayCts =
new CancellationTokenSource();
594 var gatewayCancellationToken = gatewayCts.Token;
595 var gatewayClient = serviceProvider.GetRequiredService<DiscordGatewayClient>();
597 Task<Result> localGatewayTask;
598 gatewayReadyTcs =
new TaskCompletionSource();
600 using var gatewayConnectionAbortRegistration = cancellationToken.Register(() => gatewayReadyTcs.TrySetCanceled(cancellationToken));
601 gatewayCancellationToken.Register(() => Logger.LogTrace(
"Stopping gateway client..."));
604 localGatewayTask = gatewayClient.RunAsync(gatewayCancellationToken);
607 await Task.WhenAny(gatewayReadyTcs.Task, localGatewayTask);
609 cancellationToken.ThrowIfCancellationRequested();
610 if (localGatewayTask.IsCompleted)
613 var userClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
615 using var localCombinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, gatewayCancellationToken);
616 var localCombinedCancellationToken = localCombinedCts.Token;
617 var currentUserResult = await userClient.GetCurrentUserAsync(localCombinedCancellationToken);
618 if (!currentUserResult.IsSuccess)
620 localCombinedCancellationToken.ThrowIfCancellationRequested();
621 Logger.LogWarning(
"Unable to retrieve current user: {result}", currentUserResult.LogFormat());
625 currentUserId = currentUserResult.Entity.ID;
629 gatewayTask = localGatewayTask;
636 await DisconnectImpl(CancellationToken.None);
642 protected override async ValueTask DisconnectImpl(CancellationToken cancellationToken)
644 Task<Result> localGatewayTask;
645 CancellationTokenSource localGatewayCts;
646 lock (connectDisconnectLock)
648 localGatewayTask = gatewayTask!;
649 localGatewayCts = gatewayCts!;
652 if (localGatewayTask ==
null)
656 localGatewayCts.Cancel();
657 var gatewayResult = await localGatewayTask;
659 Logger.LogTrace(
"Gateway task complete");
660 if (!gatewayResult.IsSuccess)
661 Logger.LogWarning(
"Gateway issue: {result}", gatewayResult.LogFormat());
663 localGatewayCts.Dispose();
667 protected override async ValueTask<Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> MapChannelsImpl(IEnumerable<Models.ChatChannel> channels, CancellationToken cancellationToken)
669 ArgumentNullException.ThrowIfNull(channels);
671 var remapRequired =
false;
672 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
673 var guildTasks =
new ConcurrentDictionary<Snowflake, Task<Result<IGuild>>>();
675 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>?> GetModelChannelFromDBChannel(Models.ChatChannel channelFromDB)
677 if (!channelFromDB.DiscordChannelId.HasValue)
678 throw new InvalidOperationException(
"ChatChannel missing DiscordChannelId!");
680 var channelId = channelFromDB.DiscordChannelId.Value;
681 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
682 var discordChannelResponse = await channelsClient.GetChannelAsync(
new Snowflake(channelId), cancellationToken);
683 if (!discordChannelResponse.IsSuccess)
686 "Error retrieving discord channel {channelId}: {result}",
688 discordChannelResponse.LogFormat());
690 var remapConditional = !(discordChannelResponse.Error is RestResultError<RestError> restResultError
691 && (restResultError.Error?.Code == DiscordError.MissingAccess
692 || restResultError.Error?.Code == DiscordError.UnknownChannel));
694 if (remapConditional)
700 "Error on channel {channelId} is not an access/thread issue. Will retry remap...",
702 remapRequired =
true;
708 var channelType = discordChannelResponse.Entity.Type;
709 if (!SupportedGuildChannelTypes.Contains(channelType))
711 Logger.LogWarning(
"Cound not map channel {channelId}! Incorrect type: {channelType}", channelId, discordChannelResponse.Entity.Type);
715 var guildId = discordChannelResponse.Entity.GuildID.Value;
718 var guildsResponse = await guildTasks.GetOrAdd(
723 return guildsClient.GetGuildAsync(
728 if (!guildsResponse.IsSuccess)
733 "Error retrieving discord guild {guildID}: {result}",
735 guildsResponse.LogFormat());
736 remapRequired =
true;
742 var connectionName = guildsResponse.Entity.Name;
744 var channelModel =
new ChannelRepresentation(
745 guildsResponse.Entity.Name,
746 discordChannelResponse.Entity.Name.Value!,
749 IsAdminChannel = channelFromDB.IsAdminChannel ==
true,
750 IsPrivateChannel =
false,
751 Tag = channelFromDB.Tag,
752 EmbedsSupported =
true,
755 Logger.LogTrace(
"Mapped channel {realId}: {friendlyName}", channelModel.RealId, channelModel.FriendlyName);
756 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
758 new List<ChannelRepresentation> { channelModel });
762 .Where(x => x.DiscordChannelId != 0)
763 .Select(GetModelChannelFromDBChannel);
767 var list = channelTuples
768 .Where(x => x !=
null)
769 .Cast<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>()
772 var channelIdZeroModel = channels.FirstOrDefault(x => x.DiscordChannelId == 0);
773 if (channelIdZeroModel !=
null)
775 Logger.LogInformation(
"Mapping ALL additional accessible text channels");
776 var allAccessibleChannels = await GetAllAccessibleTextChannels(cancellationToken);
777 var unmappedTextChannels = allAccessibleChannels
778 .Where(x => !tasks.Any(task => task.Result !=
null &&
new Snowflake(task.Result.Item1.DiscordChannelId!.Value) == x.ID));
780 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> CreateMappingsForUnmappedChannels()
783 unmappedTextChannels.Select(
784 async unmappedTextChannel =>
788 DiscordChannelId = unmappedTextChannel.ID.Value,
790 Tag = channelIdZeroModel.Tag,
793 var tuple = await GetModelChannelFromDBChannel(fakeChannelModel);
794 return tuple?.Item2.First();
799 unmappedTasks.Add(Task.FromResult<ChannelRepresentation?>(
800 new ChannelRepresentation(
801 "(Unknown Discord Guilds)",
802 "(Unknown Discord Channels)",
805 IsAdminChannel = channelIdZeroModel.IsAdminChannel!.Value,
806 EmbedsSupported =
true,
807 Tag = channelIdZeroModel.Tag,
810 await Task.WhenAll(unmappedTasks);
811 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
814 .Select(x => x.Result)
815 .Where(x => x !=
null)
816 .Cast<ChannelRepresentation>()
820 var task = CreateMappingsForUnmappedChannels();
821 var tuple = await task;
825 lock (mappedChannels)
827 mappedChannels.Clear();
828 mappedChannels.AddRange(list.SelectMany(x => x.Item2).Select(x => x.RealId));
833 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.");
834 EnqueueMessage(
null);
837 return new Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(list.Select(x =>
new KeyValuePair<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(x.Item1, x.Item2)));
845 async ValueTask<IEnumerable<IChannel>> GetAllAccessibleTextChannels(CancellationToken cancellationToken)
847 var usersClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
848 var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken);
849 if (!currentGuildsResponse.IsSuccess)
852 "Error retrieving current discord guilds: {result}",
853 currentGuildsResponse.LogFormat());
854 return Enumerable.Empty<IChannel>();
857 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
859 async ValueTask<IEnumerable<IChannel>> GetGuildChannels(IPartialGuild guild)
861 var channelsTask = guildsClient.GetGuildChannelsAsync(guild.ID.Value, cancellationToken);
862 var threads = await guildsClient.ListActiveGuildThreadsAsync(guild.ID.Value, cancellationToken);
863 if (!threads.IsSuccess)
865 "Error retrieving discord guild threads {guildId} ({guildName}): {result}",
868 threads.LogFormat());
870 var channels = await channelsTask;
871 if (!channels.IsSuccess)
873 "Error retrieving discord guild channels {guildId} ({guildName}): {result}",
876 channels.LogFormat());
878 if (!channels.IsSuccess && !threads.IsSuccess)
879 return Enumerable.Empty<IChannel>();
881 if (channels.IsSuccess && threads.IsSuccess)
882 return channels.Entity.Concat(threads.Entity.Threads ?? Enumerable.Empty<IChannel>());
884 return channels.Entity ?? threads.Entity?.Threads ?? Enumerable.Empty<IChannel>();
887 var guildsChannelsTasks = currentGuildsResponse.Entity
888 .Select(GetGuildChannels);
892 var allAccessibleChannels = guildsChannels
893 .SelectMany(channels => channels)
894 .Where(guildChannel => SupportedGuildChannelTypes.Contains(guildChannel.Type));
896 return allAccessibleChannels;
908 List<IEmbedField> BuildUpdateEmbedFields(
909 Models.RevisionInformation revisionInformation,
913 bool localCommitPushed)
915 bool gitHub = gitHubOwner !=
null && gitHubRepo !=
null;
916 var engineField = engineVersion.
Engine!.Value
switch
918 EngineType.Byond =>
new EmbedField(
920 $
"{engineVersion.Version!.Major}.{engineVersion.Version.Minor}{(engineVersion.CustomIteration.HasValue ? $".{engineVersion.
CustomIteration.Value}
" : String.Empty)}",
924 $
"[{engineVersion.SourceSHA![..7]}]({generalConfiguration.OpenDreamGitUrl}/commit/{engineVersion.SourceSHA})",
926 _ =>
throw new InvalidOperationException($
"Invaild EngineType: {engineVersion.Engine.Value}"),
929 var revisionSha = revisionInformation.CommitSha!;
930 var revisionOriginSha = revisionInformation.OriginCommitSha!;
931 var fields =
new List<IEmbedField>
936 if (gitHubOwner ==
null || gitHubRepo ==
null)
942 localCommitPushed && gitHub
943 ? $
"[{revisionSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionSha})"
951 ? $
"[{revisionOriginSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionOriginSha})"
952 : revisionOriginSha[..7],
955 fields.AddRange((revisionInformation.ActiveTestMerges ?? Enumerable.Empty<
RevInfoTestMerge>())
956 .Select(x => x.TestMerge)
957 .Select(x =>
new EmbedField(
959 $
"[{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}**_
")}",
970#pragma warning disable CA1502
971 Optional<IReadOnlyList<IEmbed>> ConvertEmbed(
ChatEmbed? embed)
976 var embedErrors =
new List<string>();
977 Optional<Color> colour =
default;
979 if (Int32.TryParse(embed.
Colour[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var argb))
980 colour = Color.FromArgb(argb);
984 CultureInfo.InvariantCulture,
985 "Invalid embed colour: {0}",
990 embedErrors.Add(
"Null or whitespace embed author name!");
994 List<IEmbedField>? fields =
null;
997 fields =
new List<IEmbedField>();
999 foreach (var field
in embed.
Fields)
1002 var invalid =
false;
1003 if (String.IsNullOrWhiteSpace(field.Name))
1007 CultureInfo.InvariantCulture,
1008 "Null or whitespace field name at index {0}!",
1013 if (String.IsNullOrWhiteSpace(field.Value))
1017 CultureInfo.InvariantCulture,
1018 "Null or whitespace field value at index {0}!",
1026 fields.Add(
new EmbedField(field.Name!, field.Value!)
1028 IsInline = field.IsInline ?? default(Optional<bool>),
1035 embedErrors.Add(
"Null or whitespace embed footer text!");
1036 embed.Footer =
null;
1039 if (embed.
Image !=
null && String.IsNullOrWhiteSpace(embed.
Image.
Url))
1041 embedErrors.Add(
"Null or whitespace embed image url!");
1047 embedErrors.Add(
"Null or whitespace embed thumbnail url!");
1048 embed.Thumbnail =
null;
1051 Optional<DateTimeOffset> timestampOptional =
default;
1053 if (DateTimeOffset.TryParse(embed.
Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var timestamp))
1054 timestampOptional = timestamp.ToUniversalTime();
1058 CultureInfo.InvariantCulture,
1059 "Invalid embed timestamp: {0}",
1062 var discordEmbed =
new Embed
1064 Author = embed.Author !=
null
1067 IconUrl = embed.Author.IconUrl ??
default(Optional<string>),
1068 ProxyIconUrl = embed.Author.ProxyIconUrl ??
default(Optional<string>),
1069 Url = embed.Author.Url ??
default(Optional<string>),
1071 :
default(Optional<IEmbedAuthor>),
1073 Description = embed.Description ??
default(Optional<string>),
1074 Fields = fields ??
default(
Optional<IReadOnlyList<IEmbedField>>),
1075 Footer = embed.Footer !=
null
1076 ? (Optional<IEmbedFooter>)
new EmbedFooter(embed.
Footer.
Text!)
1078 IconUrl = embed.Footer.IconUrl ??
default(Optional<string>),
1079 ProxyIconUrl = embed.Footer.ProxyIconUrl ??
default(Optional<string>),
1082 Image = embed.Image !=
null
1085 Width = embed.Image.Width ??
default(Optional<int>),
1086 Height = embed.Image.Height ??
default(Optional<int>),
1087 ProxyUrl = embed.Image.ProxyUrl ??
default(Optional<string>),
1089 :
default(Optional<IEmbedImage>),
1090 Provider = embed.Provider !=
null
1093 Name = embed.Provider.Name ??
default(Optional<string>),
1094 Url = embed.Provider.Url ??
default(Optional<string>),
1096 :
default(Optional<IEmbedProvider>),
1097 Thumbnail = embed.Thumbnail !=
null
1100 Width = embed.Thumbnail.Width ??
default(Optional<int>),
1101 Height = embed.Thumbnail.Height ??
default(Optional<int>),
1102 ProxyUrl = embed.Thumbnail.ProxyUrl ??
default(Optional<string>),
1104 :
default(Optional<IEmbedThumbnail>),
1105 Timestamp = timestampOptional,
1106 Title = embed.Title ??
default(Optional<string>),
1107 Url = embed.Url ??
default(Optional<string>),
1108 Video = embed.Video !=
null
1111 Url = embed.Video.Url ??
default(Optional<string>),
1112 Width = embed.Video.Width ??
default(Optional<int>),
1113 Height = embed.Video.Height ??
default(Optional<int>),
1114 ProxyUrl = embed.Video.ProxyUrl ??
default(Optional<string>),
1116 :
default(Optional<IEmbedVideo>),
1119 var result =
new List<IEmbed> { discordEmbed };
1121 if (embedErrors.Count > 0)
1123 var joinedErrors = String.Join(Environment.NewLine, embedErrors);
1124 Logger.LogError(
"Embed description contains errors:{newLine}{issues}", Environment.NewLine, joinedErrors);
1125 result.Add(
new Embed
1127 Title =
"TGS Embed Errors",
1128 Description = joinedErrors,
1130 Footer =
new EmbedFooter(
"Please report this to your codebase's maintainers."),
1131 Timestamp = DateTimeOffset.UtcNow,
1137 #pragma warning restore CA1502
1139#pragma warning restore CA1506
Indicates a chat channel.
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 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.
Operation exceptions thrown from the context of a Models.Job.
Many to many relationship for Models.RevisionInformation and Models.TestMerge.
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.
@ List
User may list files if the Models.Instance allows it.