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
53 throw new InvalidOperationException(
"Provider not connected");
63 ChannelType.GuildText,
64 ChannelType.GuildAnnouncement,
65 ChannelType.PrivateThread,
66 ChannelType.PublicThread,
134 static string NormalizeMentions(
string fromDiscord) => fromDiscord.Replace(
"<@!",
"<@", StringComparison.Ordinal);
148 ILogger<DiscordProvider> logger,
152 : base(
jobManager, asyncDelayer, logger, chatBot)
161 var botToken = csb.BotToken!;
167 .Configure<DiscordGatewayClientOptions>(options => options.Intents |= GatewayIntents.MessageContents)
169 .AddResponder<DiscordForwardingResponder>()
170 .BuildServiceProvider();
184 await base.DisposeAsync();
188 Logger.LogTrace(
"ServiceProvider disposed");
199 ArgumentNullException.ThrowIfNull(message);
201 Optional<IMessageReference> replyToReference =
default;
202 Optional<IAllowedMentions> allowedMentions =
default;
205 replyToReference = discordMessage.MessageReference;
206 allowedMentions =
new AllowedMentions(
207 Parse:
new List<MentionType>
209 MentionType.Everyone,
213 MentionRepliedUser:
false);
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;
270 unmappedTextChannels = allAccessibleTextChannels
274 var remapRequired = unmappedTextChannels.Any()
276 mappedChannel => !allAccessibleTextChannels.Any(
277 accessibleTextChannel => accessibleTextChannel.ID ==
new Snowflake(mappedChannel)));
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,
306 Models.RevisionInformation? previousRevisionInformation,
308 DateTimeOffset? estimatedCompletionTime,
312 bool localCommitPushed,
313 CancellationToken cancellationToken)
315 ArgumentNullException.ThrowIfNull(revisionInformation);
316 ArgumentNullException.ThrowIfNull(engineVersion);
318 localCommitPushed |= revisionInformation.CommitSha == revisionInformation.OriginCommitSha;
320 var fields =
BuildUpdateEmbedFields(revisionInformation, previousRevisionInformation, engineVersion, gitHubOwner, gitHubRepo, localCommitPushed);
323 Url =
"https://github.com/tgstation/tgstation-server",
324 IconUrl =
"https://cdn.discordapp.com/attachments/1114451486374637629/1151650846019432448/tgs.png",
326 var embed =
new Embed
329 Colour = Color.FromArgb(0xF1, 0xC4, 0x0F),
330 Description =
"TGS has begun deploying active repository code to production.",
332 Title =
"Code Deployment",
333 Footer =
new EmbedFooter(
334 $
"In progress...{(estimatedCompletionTime.HasValue ? " ETA
" : String.Empty)}"),
335 Timestamp = estimatedCompletionTime ??
default,
338 Logger.LogTrace(
"Attempting to post deploy embed to channel {channelId}...", channelId);
339 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
342 var messageResponse = await channelsClient.CreateMessageAsync(
343 new Snowflake(channelId),
344 $
"{prefix}: Deployment in progress...",
345 embeds:
new List<IEmbed> { embed },
346 ct: cancellationToken);
348 if (!messageResponse.IsSuccess)
349 Logger.LogWarning(
"Failed to post deploy embed to channel {channelId}: {result}", channelId, messageResponse.LogFormat());
351 return async (errorMessage, dreamMakerOutput) =>
353 var completionString = errorMessage ==
null ?
"Pending" :
"Failed";
355 Embed CreateUpdatedEmbed(
string message, Color color) =>
new()
357 Author = embed.Author,
359 Description = message,
362 Footer =
new EmbedFooter(
364 Timestamp = DateTimeOffset.UtcNow,
367 if (errorMessage ==
null)
368 embed = CreateUpdatedEmbed(
369 "The deployment completed successfully and will be available at the next server reboot.",
372 embed = CreateUpdatedEmbed(
373 "The deployment failed.",
378 DiscordDMOutputDisplayType.Always =>
true,
379 DiscordDMOutputDisplayType.Never =>
false,
380 DiscordDMOutputDisplayType.OnError => errorMessage !=
null,
381 _ =>
throw new InvalidOperationException($
"Invalid DiscordDMOutputDisplayType: {outputDisplayType}"),
384 if (dreamMakerOutput !=
null)
387 const int MaxFieldValueLength = 1024;
388 showDMOutput = showDMOutput && dreamMakerOutput.Length < MaxFieldValueLength - (6 + Environment.NewLine.Length);
390 fields.Add(
new EmbedField(
392 $
"```{Environment.NewLine}{dreamMakerOutput}{Environment.NewLine}```",
396 if (errorMessage !=
null)
397 fields.Add(
new EmbedField(
402 var updatedMessageText = errorMessage ==
null ? $
"{prefix}: Deployment pending reboot..." : $
"{prefix}: Deployment failed!";
404 IMessage? updatedMessage =
null;
405 async ValueTask CreateUpdatedMessage()
407 var createUpdatedMessageResponse = await channelsClient.CreateMessageAsync(
408 new Snowflake(channelId),
410 embeds:
new List<IEmbed> { embed },
411 ct: cancellationToken);
413 if (!createUpdatedMessageResponse.IsSuccess)
415 "Creating updated deploy embed failed: {result}",
416 createUpdatedMessageResponse.LogFormat());
418 updatedMessage = createUpdatedMessageResponse.Entity;
421 if (!messageResponse.IsSuccess)
422 await CreateUpdatedMessage();
425 var editResponse = await channelsClient.EditMessageAsync(
426 new Snowflake(channelId),
427 messageResponse.Entity.ID,
429 embeds:
new List<IEmbed> { embed },
430 ct: cancellationToken);
432 if (!editResponse.IsSuccess)
435 "Updating deploy embed {messageId} failed, attempting new post: {result}",
436 messageResponse.Entity.ID,
437 editResponse.LogFormat());
438 await CreateUpdatedMessage();
441 updatedMessage = editResponse.Entity;
444 return async (active) =>
446 if (updatedMessage ==
null || errorMessage !=
null)
451 completionString =
"Succeeded";
452 updatedMessageText = $
"{prefix}: Deployment succeeded!";
453 embed = CreateUpdatedEmbed(
454 "The deployment completed successfully and was applied to server.",
459 completionString =
"Inactive";
460 embed = CreateUpdatedEmbed(
461 "This deployment has been superceeded by a new one.",
465 var editResponse = await channelsClient.EditMessageAsync(
466 new Snowflake(channelId),
469 embeds:
new List<IEmbed> { embed },
470 ct: cancellationToken);
472 if (!editResponse.IsSuccess)
474 "Finalizing deploy embed {messageId} failed: {result}",
475 messageResponse.Entity.ID,
476 editResponse.LogFormat());
482 public async Task<Result>
RespondAsync(IMessageCreate messageCreateEvent, CancellationToken cancellationToken)
484 ArgumentNullException.ThrowIfNull(messageCreateEvent);
486 if ((messageCreateEvent.Type != MessageType.Default
487 && messageCreateEvent.Type != MessageType.InlineReply)
489 return Result.FromSuccess();
491 var messageReference =
new MessageReference
493 ChannelID = messageCreateEvent.ChannelID,
494 GuildID = messageCreateEvent.GuildID,
495 MessageID = messageCreateEvent.ID,
496 FailIfNotExists =
false,
499 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
500 var channelResponse = await channelsClient.GetChannelAsync(messageCreateEvent.ChannelID, cancellationToken);
501 if (!channelResponse.IsSuccess)
504 "Failed to get channel {channelId} in response to message {messageId}!",
505 messageCreateEvent.ChannelID,
506 messageCreateEvent.ID);
509 return Result.FromSuccess();
512 var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM;
513 var shouldNotAnswer = !pm;
519 var mentionedUs = messageCreateEvent.Mentions.Any(x => x.ID ==
currentUserId)
520 || (!shouldNotAnswer && content.Split(
' ').First().Equals(
ChatManager.
CommonMention, StringComparison.OrdinalIgnoreCase));
526 "Ignoring mention from {channelId} ({channelName}) by {authorId} ({authorName}). Channel not mapped!",
527 messageCreateEvent.ChannelID,
528 channelResponse.Entity.Name,
529 messageCreateEvent.Author.ID,
530 messageCreateEvent.Author.Username);
532 return Result.FromSuccess();
535 string guildName =
"UNKNOWN";
538 var guildsClient =
serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
539 var messageGuildResponse = await guildsClient.GetGuildAsync(messageCreateEvent.GuildID.Value,
false, cancellationToken);
540 if (messageGuildResponse.IsSuccess)
541 guildName = messageGuildResponse.Entity.Name;
544 "Failed to get channel {channelID} in response to message {messageID}: {result}",
545 messageCreateEvent.ChannelID,
546 messageCreateEvent.ID,
547 messageGuildResponse.LogFormat());
553 pm ? messageCreateEvent.Author.Username : guildName,
554 channelResponse.Entity.Name.Value!,
555 messageCreateEvent.ChannelID.Value)
557 IsPrivateChannel = pm,
558 EmbedsSupported =
true,
562 messageCreateEvent.Author.Username,
564 messageCreateEvent.Author.ID.Value),
569 return Result.FromSuccess();
573 public Task<Result>
RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
575 ArgumentNullException.ThrowIfNull(readyEvent);
577 Logger.LogTrace(
"Gatway ready. Version: {version}", readyEvent.Version);
579 return Task.FromResult(Result.FromSuccess());
583 protected override async ValueTask
Connect(CancellationToken cancellationToken)
590 throw new InvalidOperationException(
"Discord gateway still active!");
595 var gatewayCancellationToken =
gatewayCts.Token;
596 var gatewayClient =
serviceProvider.GetRequiredService<DiscordGatewayClient>();
598 Task<Result> localGatewayTask;
601 using var gatewayConnectionAbortRegistration = cancellationToken.Register(() =>
gatewayReadyTcs.TrySetCanceled(cancellationToken));
602 gatewayCancellationToken.Register(() =>
Logger.LogTrace(
"Stopping gateway client..."));
605 localGatewayTask = gatewayClient.RunAsync(gatewayCancellationToken);
610 cancellationToken.ThrowIfCancellationRequested();
611 if (localGatewayTask.IsCompleted)
614 var userClient =
serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
616 using var localCombinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, gatewayCancellationToken);
617 var localCombinedCancellationToken = localCombinedCts.Token;
618 var currentUserResult = await userClient.GetCurrentUserAsync(localCombinedCancellationToken);
619 if (!currentUserResult.IsSuccess)
621 localCombinedCancellationToken.ThrowIfCancellationRequested();
622 Logger.LogWarning(
"Unable to retrieve current user: {result}", currentUserResult.LogFormat());
643 protected override async ValueTask
DisconnectImpl(CancellationToken cancellationToken)
645 Task<Result> localGatewayTask;
646 CancellationTokenSource localGatewayCts;
653 if (localGatewayTask ==
null)
657 localGatewayCts.Cancel();
658 var gatewayResult = await localGatewayTask;
660 Logger.LogTrace(
"Gateway task complete");
661 if (!gatewayResult.IsSuccess)
662 Logger.LogWarning(
"Gateway issue: {result}", gatewayResult.LogFormat());
664 localGatewayCts.Dispose();
668 protected override async ValueTask<Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>
MapChannelsImpl(IEnumerable<Models.ChatChannel> channels, CancellationToken cancellationToken)
670 ArgumentNullException.ThrowIfNull(channels);
672 var remapRequired =
false;
673 var guildsClient =
serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
674 var guildTasks =
new ConcurrentDictionary<Snowflake, Task<Result<IGuild>>>();
676 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>?> GetModelChannelFromDBChannel(Models.ChatChannel channelFromDB)
678 if (!channelFromDB.DiscordChannelId.HasValue)
679 throw new InvalidOperationException(
"ChatChannel missing DiscordChannelId!");
681 var channelId = channelFromDB.DiscordChannelId.Value;
682 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
683 var discordChannelResponse = await channelsClient.GetChannelAsync(
new Snowflake(channelId), cancellationToken);
684 if (!discordChannelResponse.IsSuccess)
687 "Error retrieving discord channel {channelId}: {result}",
689 discordChannelResponse.LogFormat());
691 var remapConditional = !(discordChannelResponse.Error is RestResultError<RestError> restResultError
692 && (restResultError.Error?.Code == DiscordError.MissingAccess
693 || restResultError.Error?.Code == DiscordError.UnknownChannel));
695 if (remapConditional)
701 "Error on channel {channelId} is not an access/thread issue. Will retry remap...",
703 remapRequired =
true;
709 var channelType = discordChannelResponse.Entity.Type;
712 Logger.LogWarning(
"Cound not map channel {channelId}! Incorrect type: {channelType}", channelId, discordChannelResponse.Entity.Type);
716 var guildId = discordChannelResponse.Entity.GuildID.Value;
719 var guildsResponse = await guildTasks.GetOrAdd(
724 return guildsClient.GetGuildAsync(
729 if (!guildsResponse.IsSuccess)
734 "Error retrieving discord guild {guildID}: {result}",
736 guildsResponse.LogFormat());
737 remapRequired =
true;
743 var connectionName = guildsResponse.Entity.Name;
746 guildsResponse.Entity.Name,
747 discordChannelResponse.Entity.Name.Value!,
750 IsAdminChannel = channelFromDB.IsAdminChannel ==
true,
751 IsPrivateChannel =
false,
752 Tag = channelFromDB.
Tag,
753 EmbedsSupported =
true,
756 Logger.LogTrace(
"Mapped channel {realId}: {friendlyName}", channelModel.RealId, channelModel.FriendlyName);
757 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
759 new List<ChannelRepresentation> { channelModel });
763 .Where(x => x.DiscordChannelId != 0)
764 .Select(GetModelChannelFromDBChannel);
768 var list = channelTuples
769 .Where(x => x !=
null)
770 .Cast<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>()
773 var channelIdZeroModel = channels.FirstOrDefault(x => x.DiscordChannelId == 0);
774 if (channelIdZeroModel !=
null)
776 Logger.LogInformation(
"Mapping ALL additional accessible text channels");
778 var unmappedTextChannels = allAccessibleChannels
779 .Where(x => !tasks.Any(task => task.Result !=
null &&
new Snowflake(task.Result.Item1.DiscordChannelId!.Value) == x.ID));
781 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> CreateMappingsForUnmappedChannels()
784 unmappedTextChannels.Select(
785 async unmappedTextChannel =>
789 DiscordChannelId = unmappedTextChannel.ID.Value,
791 Tag = channelIdZeroModel.Tag,
794 var tuple = await GetModelChannelFromDBChannel(fakeChannelModel);
795 return tuple?.Item2.First();
802 "(Unknown Discord Guilds)",
803 "(Unknown Discord Channels)",
807 EmbedsSupported =
true,
808 Tag = channelIdZeroModel.Tag,
811 await Task.WhenAll(unmappedTasks);
812 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
815 .Select(x => x.Result)
816 .Where(x => x !=
null)
821 var task = CreateMappingsForUnmappedChannels();
822 var tuple = await task;
829 mappedChannels.AddRange(list.SelectMany(x => x.Item2).Select(x => x.RealId));
834 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.");
838 return new Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(list.Select(x =>
new KeyValuePair<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(x.Item1, x.Item2)));
848 var usersClient =
serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
849 var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken);
850 if (!currentGuildsResponse.IsSuccess)
853 "Error retrieving current discord guilds: {result}",
854 currentGuildsResponse.LogFormat());
855 return Enumerable.Empty<IChannel>();
858 var guildsClient =
serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
860 async ValueTask<IEnumerable<IChannel>> GetGuildChannels(IPartialGuild guild)
862 var channelsTask = guildsClient.GetGuildChannelsAsync(guild.ID.Value, cancellationToken);
863 var threads = await guildsClient.ListActiveGuildThreadsAsync(guild.ID.Value, cancellationToken);
864 if (!threads.IsSuccess)
866 "Error retrieving discord guild threads {guildId} ({guildName}): {result}",
869 threads.LogFormat());
871 var channels = await channelsTask;
872 if (!channels.IsSuccess)
874 "Error retrieving discord guild channels {guildId} ({guildName}): {result}",
877 channels.LogFormat());
879 if (!channels.IsSuccess && !threads.IsSuccess)
880 return Enumerable.Empty<IChannel>();
882 if (channels.IsSuccess && threads.IsSuccess)
883 return channels.Entity.Concat(threads.Entity.Threads ?? Enumerable.Empty<IChannel>());
885 return channels.Entity ?? threads.Entity?.Threads ?? Enumerable.Empty<IChannel>();
888 var guildsChannelsTasks = currentGuildsResponse.Entity
889 .Select(GetGuildChannels);
893 var allAccessibleChannels = guildsChannels
894 .SelectMany(channels => channels)
897 return allAccessibleChannels;
911 Models.RevisionInformation revisionInformation,
912 Models.RevisionInformation? previousRevisionInformation,
916 bool localCommitPushed)
918 bool gitHub = gitHubOwner !=
null && gitHubRepo !=
null;
919 var engineField = engineVersion.
Engine!.Value
switch
921 EngineType.Byond =>
new EmbedField(
923 $
"{engineVersion.Version!.Major}.{engineVersion.Version.Minor}{(engineVersion.CustomIteration.HasValue ? $".{engineVersion.
CustomIteration.Value}
" : String.Empty)}",
927 $
"[{engineVersion.SourceSHA![..7]}]({generalConfiguration.OpenDreamGitUrl}/commit/{engineVersion.SourceSHA})",
929 _ =>
throw new InvalidOperationException($
"Invaild EngineType: {engineVersion.Engine.Value}"),
932 var revisionSha = revisionInformation.CommitSha!;
933 var revisionOriginSha = revisionInformation.OriginCommitSha!;
934 var fields =
new List<IEmbedField>
939 if (gitHubOwner ==
null || gitHubRepo ==
null)
942 var previousTestMerges = (IEnumerable<RevInfoTestMerge>?)previousRevisionInformation?.ActiveTestMerges ?? Enumerable.Empty<
RevInfoTestMerge>();
943 var currentTestMerges = (IEnumerable<RevInfoTestMerge>?)revisionInformation.ActiveTestMerges ?? Enumerable.Empty<
RevInfoTestMerge>();
946 var addedTestMerges = currentTestMerges
947 .Select(x => x.TestMerge)
948 .Where(x => !previousTestMerges
949 .Any(y => y.TestMerge.Number == x.Number))
951 var removedTestMerges = previousTestMerges
952 .Select(x => x.TestMerge)
953 .Where(x => !currentTestMerges
954 .Any(y => y.TestMerge.Number == x.Number))
956 var updatedTestMerges = currentTestMerges
957 .Select(x => x.TestMerge)
958 .Where(x => previousTestMerges
959 .Any(y => y.TestMerge.Number == x.Number && y.TestMerge.TargetCommitSha != x.TargetCommitSha))
961 var unchangedTestMerges = currentTestMerges
962 .Select(x => x.TestMerge)
963 .Where(x => previousTestMerges
964 .Any(y => y.TestMerge.Number == x.Number && y.TestMerge.TargetCommitSha == x.TargetCommitSha))
970 localCommitPushed && gitHub
971 ? $
"[{revisionSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionSha})"
979 ? $
"[{revisionOriginSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionOriginSha})"
980 : revisionOriginSha[..7],
983 fields.AddRange(addedTestMerges
984 .Select(x =>
new EmbedField(
985 $
"#{x.Number} (Added)",
986 $
"[{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}**_
")}",
989 fields.AddRange(updatedTestMerges
990 .Select(x =>
new EmbedField(
991 $
"#{x.Number} (Updated)",
992 $
"[{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}**_
")}",
995 fields.AddRange(unchangedTestMerges
996 .Select(x =>
new EmbedField(
998 $
"[{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}**_
")}",
1001 if (removedTestMerges.Count != 0)
1006 Environment.NewLine,
1008 .Select(x => $
"- #{x.Number} [{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_"))));
1018#pragma warning disable CA1502
1024 var embedErrors =
new List<string>();
1025 Optional<Color> colour =
default;
1026 if (embed.
Colour !=
null)
1027 if (Int32.TryParse(embed.
Colour[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var argb))
1028 colour = Color.FromArgb(argb);
1032 CultureInfo.InvariantCulture,
1033 "Invalid embed colour: {0}",
1038 embedErrors.Add(
"Null or whitespace embed author name!");
1039 embed.Author =
null;
1042 List<IEmbedField>? fields =
null;
1043 if (embed.
Fields !=
null)
1045 fields =
new List<IEmbedField>();
1047 foreach (var field
in embed.
Fields)
1050 var invalid =
false;
1051 if (String.IsNullOrWhiteSpace(field.Name))
1055 CultureInfo.InvariantCulture,
1056 "Null or whitespace field name at index {0}!",
1061 if (String.IsNullOrWhiteSpace(field.Value))
1065 CultureInfo.InvariantCulture,
1066 "Null or whitespace field value at index {0}!",
1074 fields.Add(
new EmbedField(field.Name!, field.Value!)
1076 IsInline = field.IsInline ?? default(Optional<bool>),
1083 embedErrors.Add(
"Null or whitespace embed footer text!");
1084 embed.Footer =
null;
1087 if (embed.
Image !=
null && String.IsNullOrWhiteSpace(embed.
Image.
Url))
1089 embedErrors.Add(
"Null or whitespace embed image url!");
1095 embedErrors.Add(
"Null or whitespace embed thumbnail url!");
1096 embed.Thumbnail =
null;
1099 Optional<DateTimeOffset> timestampOptional =
default;
1101 if (DateTimeOffset.TryParse(embed.
Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var timestamp))
1102 timestampOptional = timestamp.ToUniversalTime();
1106 CultureInfo.InvariantCulture,
1107 "Invalid embed timestamp: {0}",
1110 var discordEmbed =
new Embed
1112 Author = embed.Author !=
null
1115 IconUrl = embed.Author.IconUrl ??
default(Optional<string>),
1116 ProxyIconUrl = embed.Author.ProxyIconUrl ??
default(Optional<string>),
1117 Url = embed.Author.Url ??
default(Optional<string>),
1119 :
default(Optional<IEmbedAuthor>),
1121 Description = embed.Description ??
default(Optional<string>),
1122 Fields = fields ??
default(
Optional<IReadOnlyList<IEmbedField>>),
1123 Footer = embed.Footer !=
null
1124 ? (Optional<IEmbedFooter>)
new EmbedFooter(embed.
Footer.
Text!)
1126 IconUrl = embed.Footer.IconUrl ??
default(Optional<string>),
1127 ProxyIconUrl = embed.Footer.ProxyIconUrl ??
default(Optional<string>),
1130 Image = embed.Image !=
null
1133 Width = embed.Image.Width ??
default(Optional<int>),
1134 Height = embed.Image.Height ??
default(Optional<int>),
1135 ProxyUrl = embed.Image.ProxyUrl ??
default(Optional<string>),
1137 :
default(Optional<IEmbedImage>),
1138 Provider = embed.Provider !=
null
1141 Name = embed.Provider.Name ??
default(Optional<string>),
1142 Url = embed.Provider.Url ??
default(Optional<string>),
1144 :
default(Optional<IEmbedProvider>),
1145 Thumbnail = embed.Thumbnail !=
null
1148 Width = embed.Thumbnail.Width ??
default(Optional<int>),
1149 Height = embed.Thumbnail.Height ??
default(Optional<int>),
1150 ProxyUrl = embed.Thumbnail.ProxyUrl ??
default(Optional<string>),
1152 :
default(Optional<IEmbedThumbnail>),
1153 Timestamp = timestampOptional,
1154 Title = embed.Title ??
default(Optional<string>),
1155 Url = embed.Url ??
default(Optional<string>),
1156 Video = embed.Video !=
null
1159 Url = embed.Video.Url ??
default(Optional<string>),
1160 Width = embed.Video.Width ??
default(Optional<int>),
1161 Height = embed.Video.Height ??
default(Optional<int>),
1162 ProxyUrl = embed.Video.ProxyUrl ??
default(Optional<string>),
1164 :
default(Optional<IEmbedVideo>),
1167 var result =
new List<IEmbed> { discordEmbed };
1169 if (embedErrors.Count > 0)
1171 var joinedErrors = String.Join(Environment.NewLine, embedErrors);
1172 Logger.LogError(
"Embed description contains errors:{newLine}{issues}", Environment.NewLine, joinedErrors);
1173 result.Add(
new Embed
1175 Title =
"TGS Embed Errors",
1176 Description = joinedErrors,
1178 Footer =
new EmbedFooter(
"Please report this to your codebase's maintainers."),
1179 Timestamp = DateTimeOffset.UtcNow,
1185 #pragma warning restore CA1502
1187#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.
A Message containing the source IMessageReference.
IProvider for the Discord app.
readonly object connectDisconnectLock
Lock object used to sychronize connect/disconnect operations.
readonly List< ulong > mappedChannels
List<T> of mapped channel Snowflakes.
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.
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)
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.
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)
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.
DiscordProvider(IJobManager jobManager, IAsyncDelayer asyncDelayer, ILogger< DiscordProvider > logger, IAssemblyInformationProvider assemblyInformationProvider, ChatBot chatBot, GeneralConfiguration generalConfiguration)
Initializes a new instance of the DiscordProvider class.
override async ValueTask DisposeAsync()
async ValueTask< IEnumerable< IChannel > > GetAllAccessibleTextChannels(CancellationToken cancellationToken)
Get all text IChannels accessible to and supported by the bot.
override bool Connected
If the IProvider is currently connected.
readonly GeneralConfiguration generalConfiguration
The GeneralConfiguration for the DiscordProvider.
Task< Result > RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
readonly ServiceProvider serviceProvider
The ServiceProvider containing Discord services.
CancellationTokenSource? gatewayCts
The CancellationTokenSource for the gatewayTask.
override string BotMention
The string that indicates the IProvider was mentioned.
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.
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.