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;
14using Remora.Discord.API.Abstractions.Gateway.Commands;
15using Remora.Discord.API.Abstractions.Gateway.Events;
16using Remora.Discord.API.Abstractions.Objects;
17using Remora.Discord.API.Abstractions.Rest;
18using Remora.Discord.API.Abstractions.Results;
19using Remora.Discord.API.Objects;
20using Remora.Discord.Gateway;
21using Remora.Discord.Gateway.Extensions;
22using Remora.Rest.Core;
23using Remora.Rest.Results;
42 #pragma warning disable CA1506
54 throw new InvalidOperationException(
"Provider not connected");
64 ChannelType.GuildText,
65 ChannelType.GuildAnnouncement,
66 ChannelType.PrivateThread,
67 ChannelType.PublicThread,
135 static string NormalizeMentions(
string fromDiscord) => fromDiscord.Replace(
"<@!",
"<@", StringComparison.Ordinal);
149 ILogger<DiscordProvider> logger,
153 : base(
jobManager, asyncDelayer, logger, chatBot)
162 var botToken = csb.BotToken!;
168 .Configure<DiscordGatewayClientOptions>(options => options.Intents |= GatewayIntents.MessageContents)
170 .AddResponder<DiscordForwardingResponder>()
171 .BuildServiceProvider();
185 await base.DisposeAsync();
189 Logger.LogTrace(
"ServiceProvider disposed");
200 ArgumentNullException.ThrowIfNull(message);
202 Optional<IMessageReference> replyToReference =
default;
203 Optional<IAllowedMentions> allowedMentions =
default;
206 replyToReference = discordMessage.MessageReference;
207 allowedMentions =
new AllowedMentions(
208 Parse:
new List<MentionType>
210 MentionType.Everyone,
214 MentionRepliedUser:
false);
219 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
220 async ValueTask SendToChannel(Snowflake channelId)
222 if (message.
Text ==
null)
225 "Failed to send to channel {channelId}: Message was null!",
228 await channelsClient.CreateMessageAsync(
230 "TGS: Could not send message to Discord. Message was `null`!",
231 messageReference: replyToReference,
232 allowedMentions: allowedMentions,
233 ct: cancellationToken);
238 var result = await channelsClient.CreateMessageAsync(
242 messageReference: replyToReference,
243 allowedMentions: allowedMentions,
244 ct: cancellationToken);
246 if (!result.IsSuccess)
249 "Failed to send to channel {channelId}: {result}",
253 if (result.Error is RestResultError<RestError> restError && restError.Error.Code == DiscordError.InvalidFormBody)
254 await channelsClient.CreateMessageAsync(
256 "TGS: Could not send message to Discord. Body was malformed or too long",
257 messageReference: replyToReference,
258 allowedMentions: allowedMentions,
259 ct: cancellationToken);
267 IEnumerable<IChannel> unmappedTextChannels;
271 unmappedTextChannels = allAccessibleTextChannels
275 var remapRequired = unmappedTextChannels.Any()
277 mappedChannel => !allAccessibleTextChannels.Any(
278 accessibleTextChannel => accessibleTextChannel.ID ==
new Snowflake(mappedChannel)));
285 if (unmappedTextChannels.Any())
287 Logger.LogDebug(
"Dispatching to {count} unmapped channels...", unmappedTextChannels.Count());
289 unmappedTextChannels.Select(
290 x => SendToChannel(x.ID)));
296 await SendToChannel(
new Snowflake(channelId));
298 catch (
Exception e) when (e is not OperationCanceledException)
300 Logger.LogWarning(e,
"Error sending discord message!");
305 public override async ValueTask<Func<string?, string, ValueTask<Func<bool, ValueTask>>>>
SendUpdateMessage(
306 Models.RevisionInformation revisionInformation,
307 Models.RevisionInformation? previousRevisionInformation,
309 DateTimeOffset? estimatedCompletionTime,
313 bool localCommitPushed,
314 CancellationToken cancellationToken)
316 ArgumentNullException.ThrowIfNull(revisionInformation);
317 ArgumentNullException.ThrowIfNull(engineVersion);
319 localCommitPushed |= revisionInformation.CommitSha == revisionInformation.OriginCommitSha;
321 var fields =
BuildUpdateEmbedFields(revisionInformation, previousRevisionInformation, engineVersion, gitHubOwner, gitHubRepo, localCommitPushed);
324 Url =
"https://github.com/tgstation/tgstation-server",
325 IconUrl =
"https://cdn.discordapp.com/attachments/1114451486374637629/1151650846019432448/tgs.png",
327 var embed =
new Embed
330 Colour = Color.FromArgb(0xF1, 0xC4, 0x0F),
331 Description =
"TGS has begun deploying active repository code to production.",
333 Title =
"Code Deployment",
334 Footer =
new EmbedFooter(
335 $
"In progress...{(estimatedCompletionTime.HasValue ? " ETA
" : String.Empty)}"),
336 Timestamp = estimatedCompletionTime ??
default,
339 Logger.LogTrace(
"Attempting to post deploy embed to channel {channelId}...", channelId);
340 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
343 var messageResponse = await channelsClient.CreateMessageAsync(
344 new Snowflake(channelId),
345 $
"{prefix}: Deployment in progress...",
346 embeds:
new List<IEmbed> { embed },
347 ct: cancellationToken);
349 if (!messageResponse.IsSuccess)
350 Logger.LogWarning(
"Failed to post deploy embed to channel {channelId}: {result}", channelId, messageResponse.LogFormat());
352 return async (errorMessage, dreamMakerOutput) =>
354 var completionString = errorMessage ==
null ?
"Pending" :
"Failed";
356 Embed CreateUpdatedEmbed(
string message, Color color) =>
new()
358 Author = embed.Author,
360 Description = message,
363 Footer =
new EmbedFooter(
365 Timestamp = DateTimeOffset.UtcNow,
368 if (errorMessage ==
null)
369 embed = CreateUpdatedEmbed(
370 "The deployment completed successfully and will be available at the next server reboot.",
373 embed = CreateUpdatedEmbed(
374 "The deployment failed.",
379 DiscordDMOutputDisplayType.Always =>
true,
380 DiscordDMOutputDisplayType.Never =>
false,
381 DiscordDMOutputDisplayType.OnError => errorMessage !=
null,
382 _ =>
throw new InvalidOperationException($
"Invalid DiscordDMOutputDisplayType: {outputDisplayType}"),
385 if (dreamMakerOutput !=
null)
388 const int MaxFieldValueLength = 1024;
389 showDMOutput = showDMOutput && dreamMakerOutput.Length < MaxFieldValueLength - (6 + Environment.NewLine.Length);
391 fields.Add(
new EmbedField(
393 $
"```{Environment.NewLine}{dreamMakerOutput}{Environment.NewLine}```",
397 if (errorMessage !=
null)
398 fields.Add(
new EmbedField(
403 var updatedMessageText = errorMessage ==
null ? $
"{prefix}: Deployment pending reboot..." : $
"{prefix}: Deployment failed!";
405 IMessage? updatedMessage =
null;
406 async ValueTask CreateUpdatedMessage()
408 var createUpdatedMessageResponse = await channelsClient.CreateMessageAsync(
409 new Snowflake(channelId),
411 embeds:
new List<IEmbed> { embed },
412 ct: cancellationToken);
414 if (!createUpdatedMessageResponse.IsSuccess)
416 "Creating updated deploy embed failed: {result}",
417 createUpdatedMessageResponse.LogFormat());
419 updatedMessage = createUpdatedMessageResponse.Entity;
422 if (!messageResponse.IsSuccess)
423 await CreateUpdatedMessage();
426 var editResponse = await channelsClient.EditMessageAsync(
427 new Snowflake(channelId),
428 messageResponse.Entity.ID,
430 embeds:
new List<IEmbed> { embed },
431 ct: cancellationToken);
433 if (!editResponse.IsSuccess)
436 "Updating deploy embed {messageId} failed, attempting new post: {result}",
437 messageResponse.Entity.ID,
438 editResponse.LogFormat());
439 await CreateUpdatedMessage();
442 updatedMessage = editResponse.Entity;
445 return async (active) =>
447 if (updatedMessage ==
null || errorMessage !=
null)
452 completionString =
"Succeeded";
453 updatedMessageText = $
"{prefix}: Deployment succeeded!";
454 embed = CreateUpdatedEmbed(
455 "The deployment completed successfully and was applied to server.",
460 completionString =
"Inactive";
461 embed = CreateUpdatedEmbed(
462 "This deployment has been superceeded by a new one.",
466 var editResponse = await channelsClient.EditMessageAsync(
467 new Snowflake(channelId),
470 embeds:
new List<IEmbed> { embed },
471 ct: cancellationToken);
473 if (!editResponse.IsSuccess)
475 "Finalizing deploy embed {messageId} failed: {result}",
476 messageResponse.Entity.ID,
477 editResponse.LogFormat());
483 public async Task<Result>
RespondAsync(IMessageCreate messageCreateEvent, CancellationToken cancellationToken)
485 ArgumentNullException.ThrowIfNull(messageCreateEvent);
487 if ((messageCreateEvent.Type != MessageType.Default
488 && messageCreateEvent.Type != MessageType.InlineReply)
490 return Result.FromSuccess();
492 var messageReference =
new MessageReference
494 ChannelID = messageCreateEvent.ChannelID,
495 GuildID = messageCreateEvent.GuildID,
496 MessageID = messageCreateEvent.ID,
497 FailIfNotExists =
false,
500 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
501 var channelResponse = await channelsClient.GetChannelAsync(messageCreateEvent.ChannelID, cancellationToken);
502 if (!channelResponse.IsSuccess)
505 "Failed to get channel {channelId} in response to message {messageId}!",
506 messageCreateEvent.ChannelID,
507 messageCreateEvent.ID);
510 return Result.FromSuccess();
513 var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM;
514 var shouldNotAnswer = !pm;
520 var mentionedUs = messageCreateEvent.Mentions.Any(x => x.ID ==
currentUserId)
521 || (!shouldNotAnswer && content.Split(
' ').First().Equals(
ChatManager.
CommonMention, StringComparison.OrdinalIgnoreCase));
527 "Ignoring mention from {channelId} ({channelName}) by {authorId} ({authorName}). Channel not mapped!",
528 messageCreateEvent.ChannelID,
529 channelResponse.Entity.Name,
530 messageCreateEvent.Author.ID,
531 messageCreateEvent.Author.Username);
533 return Result.FromSuccess();
536 string guildName =
"UNKNOWN";
539 var guildsClient =
serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
540 var messageGuildResponse = await guildsClient.GetGuildAsync(messageCreateEvent.GuildID.Value,
false, cancellationToken);
541 if (messageGuildResponse.IsSuccess)
542 guildName = messageGuildResponse.Entity.Name;
545 "Failed to get channel {channelID} in response to message {messageID}: {result}",
546 messageCreateEvent.ChannelID,
547 messageCreateEvent.ID,
548 messageGuildResponse.LogFormat());
554 pm ? messageCreateEvent.Author.Username : guildName,
555 channelResponse.Entity.Name.Value!,
556 messageCreateEvent.ChannelID.Value)
558 IsPrivateChannel = pm,
559 EmbedsSupported =
true,
563 messageCreateEvent.Author.Username,
565 messageCreateEvent.Author.ID.Value),
570 return Result.FromSuccess();
574 public Task<Result>
RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
576 ArgumentNullException.ThrowIfNull(readyEvent);
578 Logger.LogTrace(
"Gatway ready. Version: {version}", readyEvent.Version);
580 return Task.FromResult(Result.FromSuccess());
584 protected override async ValueTask
Connect(CancellationToken cancellationToken)
591 throw new InvalidOperationException(
"Discord gateway still active!");
596 var gatewayCancellationToken =
gatewayCts.Token;
597 var gatewayClient =
serviceProvider.GetRequiredService<DiscordGatewayClient>();
599 Task<Result> localGatewayTask;
602 using var gatewayConnectionAbortRegistration = cancellationToken.Register(() =>
gatewayReadyTcs.TrySetCanceled(cancellationToken));
603 gatewayCancellationToken.Register(() =>
Logger.LogTrace(
"Stopping gateway client..."));
606 localGatewayTask = gatewayClient.RunAsync(gatewayCancellationToken);
611 cancellationToken.ThrowIfCancellationRequested();
612 if (localGatewayTask.IsCompleted)
615 var userClient =
serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
617 using var localCombinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, gatewayCancellationToken);
618 var localCombinedCancellationToken = localCombinedCts.Token;
619 var currentUserResult = await userClient.GetCurrentUserAsync(localCombinedCancellationToken);
620 if (!currentUserResult.IsSuccess)
622 localCombinedCancellationToken.ThrowIfCancellationRequested();
623 Logger.LogWarning(
"Unable to retrieve current user: {result}", currentUserResult.LogFormat());
644 protected override async ValueTask
DisconnectImpl(CancellationToken cancellationToken)
646 Task<Result> localGatewayTask;
647 CancellationTokenSource localGatewayCts;
654 if (localGatewayTask ==
null)
658 localGatewayCts.Cancel();
659 var gatewayResult = await localGatewayTask;
661 Logger.LogTrace(
"Gateway task complete");
662 if (!gatewayResult.IsSuccess)
663 Logger.LogWarning(
"Gateway issue: {result}", gatewayResult.LogFormat());
665 localGatewayCts.Dispose();
669 protected override async ValueTask<Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>
MapChannelsImpl(IEnumerable<Models.ChatChannel> channels, CancellationToken cancellationToken)
671 ArgumentNullException.ThrowIfNull(channels);
673 var remapRequired =
false;
674 var guildsClient =
serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
675 var guildTasks =
new ConcurrentDictionary<Snowflake, Task<Result<IGuild>>>();
677 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>?> GetModelChannelFromDBChannel(Models.ChatChannel channelFromDB)
679 if (!channelFromDB.DiscordChannelId.HasValue)
680 throw new InvalidOperationException(
"ChatChannel missing DiscordChannelId!");
682 var channelId = channelFromDB.DiscordChannelId.Value;
683 var channelsClient =
serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
684 var discordChannelResponse = await channelsClient.GetChannelAsync(
new Snowflake(channelId), cancellationToken);
685 if (!discordChannelResponse.IsSuccess)
688 "Error retrieving discord channel {channelId}: {result}",
690 discordChannelResponse.LogFormat());
692 var remapConditional = !(discordChannelResponse.Error is RestResultError<RestError> restResultError
693 && (restResultError.Error?.Code == DiscordError.MissingAccess
694 || restResultError.Error?.Code == DiscordError.UnknownChannel));
696 if (remapConditional)
702 "Error on channel {channelId} is not an access/thread issue. Will retry remap...",
704 remapRequired =
true;
710 var channelType = discordChannelResponse.Entity.Type;
713 Logger.LogWarning(
"Cound not map channel {channelId}! Incorrect type: {channelType}", channelId, discordChannelResponse.Entity.Type);
717 var guildId = discordChannelResponse.Entity.GuildID.Value;
720 var guildsResponse = await guildTasks.GetOrAdd(
725 return guildsClient.GetGuildAsync(
730 if (!guildsResponse.IsSuccess)
735 "Error retrieving discord guild {guildID}: {result}",
737 guildsResponse.LogFormat());
738 remapRequired =
true;
744 var connectionName = guildsResponse.Entity.Name;
747 guildsResponse.Entity.Name,
748 discordChannelResponse.Entity.Name.Value!,
751 IsAdminChannel = channelFromDB.IsAdminChannel ==
true,
752 IsPrivateChannel =
false,
753 Tag = channelFromDB.
Tag,
754 EmbedsSupported =
true,
757 Logger.LogTrace(
"Mapped channel {realId}: {friendlyName}", channelModel.RealId, channelModel.FriendlyName);
758 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
760 new List<ChannelRepresentation> { channelModel });
764 .Where(x => x.DiscordChannelId != 0)
765 .Select(GetModelChannelFromDBChannel);
769 var list = channelTuples
770 .Where(x => x !=
null)
771 .Cast<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>()
774 var channelIdZeroModel = channels.FirstOrDefault(x => x.DiscordChannelId == 0);
775 if (channelIdZeroModel !=
null)
777 Logger.LogInformation(
"Mapping ALL additional accessible text channels");
779 var unmappedTextChannels = allAccessibleChannels
780 .Where(x => !tasks.Any(task => task.Result !=
null &&
new Snowflake(task.Result.Item1.DiscordChannelId!.Value) == x.ID));
782 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> CreateMappingsForUnmappedChannels()
785 unmappedTextChannels.Select(
786 async unmappedTextChannel =>
790 DiscordChannelId = unmappedTextChannel.ID.Value,
792 Tag = channelIdZeroModel.Tag,
795 var tuple = await GetModelChannelFromDBChannel(fakeChannelModel);
796 return tuple?.Item2.First();
803 "(Unknown Discord Guilds)",
804 "(Unknown Discord Channels)",
808 EmbedsSupported =
true,
809 Tag = channelIdZeroModel.Tag,
812 await Task.WhenAll(unmappedTasks);
813 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
816 .Select(x => x.Result)
817 .Where(x => x !=
null)
822 var task = CreateMappingsForUnmappedChannels();
823 var tuple = await task;
830 mappedChannels.AddRange(list.SelectMany(x => x.Item2).Select(x => x.RealId));
835 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.");
839 return new Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(list.Select(x =>
new KeyValuePair<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(x.Item1, x.Item2)));
849 var usersClient =
serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
850 var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken);
851 if (!currentGuildsResponse.IsSuccess)
854 "Error retrieving current discord guilds: {result}",
855 currentGuildsResponse.LogFormat());
856 return Enumerable.Empty<IChannel>();
859 var guildsClient =
serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
861 async ValueTask<IEnumerable<IChannel>> GetGuildChannels(IPartialGuild guild)
863 var channelsTask = guildsClient.GetGuildChannelsAsync(guild.ID.Value, cancellationToken);
864 var threads = await guildsClient.ListActiveGuildThreadsAsync(guild.ID.Value, cancellationToken);
865 if (!threads.IsSuccess)
867 "Error retrieving discord guild threads {guildId} ({guildName}): {result}",
870 threads.LogFormat());
872 var channels = await channelsTask;
873 if (!channels.IsSuccess)
875 "Error retrieving discord guild channels {guildId} ({guildName}): {result}",
878 channels.LogFormat());
880 if (!channels.IsSuccess && !threads.IsSuccess)
881 return Enumerable.Empty<IChannel>();
883 if (channels.IsSuccess && threads.IsSuccess)
884 return channels.Entity.Concat(threads.Entity.Threads ?? Enumerable.Empty<IChannel>());
886 return channels.Entity ?? threads.Entity?.Threads ?? Enumerable.Empty<IChannel>();
889 var guildsChannelsTasks = currentGuildsResponse.Entity
890 .Select(GetGuildChannels);
894 var allAccessibleChannels = guildsChannels
895 .SelectMany(channels => channels)
898 return allAccessibleChannels;
912 Models.RevisionInformation revisionInformation,
913 Models.RevisionInformation? previousRevisionInformation,
917 bool localCommitPushed)
919 bool gitHub = gitHubOwner !=
null && gitHubRepo !=
null;
920 var engineField = engineVersion.
Engine!.Value
switch
922 EngineType.Byond =>
new EmbedField(
924 $
"{engineVersion.Version!.Major}.{engineVersion.Version.Minor}{(engineVersion.CustomIteration.HasValue ? $".{engineVersion.
CustomIteration.Value}
" : String.Empty)}",
928 $
"[{engineVersion.SourceSHA![..7]}]({generalConfigurationOptions.CurrentValue.OpenDreamGitUrl}/commit/{engineVersion.SourceSHA})",
930 _ =>
throw new InvalidOperationException($
"Invaild EngineType: {engineVersion.Engine.Value}"),
933 var revisionSha = revisionInformation.CommitSha!;
934 var revisionOriginSha = revisionInformation.OriginCommitSha!;
935 var fields =
new List<IEmbedField>
940 if (gitHubOwner ==
null || gitHubRepo ==
null)
943 var previousTestMerges = (IEnumerable<RevInfoTestMerge>?)previousRevisionInformation?.ActiveTestMerges ?? Enumerable.Empty<
RevInfoTestMerge>();
944 var currentTestMerges = (IEnumerable<RevInfoTestMerge>?)revisionInformation.ActiveTestMerges ?? Enumerable.Empty<
RevInfoTestMerge>();
947 var addedTestMerges = currentTestMerges
948 .Select(x => x.TestMerge)
949 .Where(x => !previousTestMerges
950 .Any(y => y.TestMerge.Number == x.Number))
952 var removedTestMerges = previousTestMerges
953 .Select(x => x.TestMerge)
954 .Where(x => !currentTestMerges
955 .Any(y => y.TestMerge.Number == x.Number))
957 var updatedTestMerges = currentTestMerges
958 .Select(x => x.TestMerge)
959 .Where(x => previousTestMerges
960 .Any(y => y.TestMerge.Number == x.Number && y.TestMerge.TargetCommitSha != x.TargetCommitSha))
962 var unchangedTestMerges = currentTestMerges
963 .Select(x => x.TestMerge)
964 .Where(x => previousTestMerges
965 .Any(y => y.TestMerge.Number == x.Number && y.TestMerge.TargetCommitSha == x.TargetCommitSha))
971 localCommitPushed && gitHub
972 ? $
"[{revisionSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionSha})"
980 ? $
"[{revisionOriginSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionOriginSha})"
981 : revisionOriginSha[..7],
984 fields.AddRange(addedTestMerges
985 .Select(x =>
new EmbedField(
986 $
"#{x.Number} (Added)",
987 $
"[{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}**_
")}",
990 fields.AddRange(updatedTestMerges
991 .Select(x =>
new EmbedField(
992 $
"#{x.Number} (Updated)",
993 $
"[{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}**_
")}",
996 fields.AddRange(unchangedTestMerges
997 .Select(x =>
new EmbedField(
999 $
"[{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}**_
")}",
1002 if (removedTestMerges.Count != 0)
1007 Environment.NewLine,
1009 .Select(x => $
"- #{x.Number} [{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_"))));
1019#pragma warning disable CA1502
1025 var embedErrors =
new List<string>();
1026 Optional<Color> colour =
default;
1027 if (embed.
Colour !=
null)
1028 if (Int32.TryParse(embed.
Colour[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var argb))
1029 colour = Color.FromArgb(argb);
1033 CultureInfo.InvariantCulture,
1034 "Invalid embed colour: {0}",
1039 embedErrors.Add(
"Null or whitespace embed author name!");
1040 embed.Author =
null;
1043 List<IEmbedField>? fields =
null;
1044 if (embed.
Fields !=
null)
1046 fields =
new List<IEmbedField>();
1048 foreach (var field
in embed.
Fields)
1051 var invalid =
false;
1052 if (String.IsNullOrWhiteSpace(field.Name))
1056 CultureInfo.InvariantCulture,
1057 "Null or whitespace field name at index {0}!",
1062 if (String.IsNullOrWhiteSpace(field.Value))
1066 CultureInfo.InvariantCulture,
1067 "Null or whitespace field value at index {0}!",
1075 fields.Add(
new EmbedField(field.Name!, field.Value!)
1077 IsInline = field.IsInline ?? default(Optional<bool>),
1084 embedErrors.Add(
"Null or whitespace embed footer text!");
1085 embed.Footer =
null;
1088 if (embed.
Image !=
null && String.IsNullOrWhiteSpace(embed.
Image.
Url))
1090 embedErrors.Add(
"Null or whitespace embed image url!");
1096 embedErrors.Add(
"Null or whitespace embed thumbnail url!");
1097 embed.Thumbnail =
null;
1100 Optional<DateTimeOffset> timestampOptional =
default;
1102 if (DateTimeOffset.TryParse(embed.
Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var timestamp))
1103 timestampOptional = timestamp.ToUniversalTime();
1107 CultureInfo.InvariantCulture,
1108 "Invalid embed timestamp: {0}",
1111 var discordEmbed =
new Embed
1113 Author = embed.Author !=
null
1116 IconUrl = embed.Author.IconUrl ??
default(Optional<string>),
1117 ProxyIconUrl = embed.Author.ProxyIconUrl ??
default(Optional<string>),
1118 Url = embed.Author.Url ??
default(Optional<string>),
1120 :
default(Optional<IEmbedAuthor>),
1122 Description = embed.Description ??
default(Optional<string>),
1123 Fields = fields ??
default(
Optional<IReadOnlyList<IEmbedField>>),
1124 Footer = embed.Footer !=
null
1125 ? (Optional<IEmbedFooter>)
new EmbedFooter(embed.
Footer.
Text!)
1127 IconUrl = embed.Footer.IconUrl ??
default(Optional<string>),
1128 ProxyIconUrl = embed.Footer.ProxyIconUrl ??
default(Optional<string>),
1131 Image = embed.Image !=
null
1134 Width = embed.Image.Width ??
default(Optional<int>),
1135 Height = embed.Image.Height ??
default(Optional<int>),
1136 ProxyUrl = embed.Image.ProxyUrl ??
default(Optional<string>),
1138 :
default(Optional<IEmbedImage>),
1139 Provider = embed.Provider !=
null
1142 Name = embed.Provider.Name ??
default(Optional<string>),
1143 Url = embed.Provider.Url ??
default(Optional<string>),
1145 :
default(Optional<IEmbedProvider>),
1146 Thumbnail = embed.Thumbnail !=
null
1149 Width = embed.Thumbnail.Width ??
default(Optional<int>),
1150 Height = embed.Thumbnail.Height ??
default(Optional<int>),
1151 ProxyUrl = embed.Thumbnail.ProxyUrl ??
default(Optional<string>),
1153 :
default(Optional<IEmbedThumbnail>),
1154 Timestamp = timestampOptional,
1155 Title = embed.Title ??
default(Optional<string>),
1156 Url = embed.Url ??
default(Optional<string>),
1157 Video = embed.Video !=
null
1160 Url = embed.Video.Url ??
default(Optional<string>),
1161 Width = embed.Video.Width ??
default(Optional<int>),
1162 Height = embed.Video.Height ??
default(Optional<int>),
1163 ProxyUrl = embed.Video.ProxyUrl ??
default(Optional<string>),
1165 :
default(Optional<IEmbedVideo>),
1168 var result =
new List<IEmbed> { discordEmbed };
1170 if (embedErrors.Count > 0)
1172 var joinedErrors = String.Join(Environment.NewLine, embedErrors);
1173 Logger.LogError(
"Embed description contains errors:{newLine}{issues}", Environment.NewLine, joinedErrors);
1174 result.Add(
new Embed
1176 Title =
"TGS Embed Errors",
1177 Description = joinedErrors,
1179 Footer =
new EmbedFooter(
"Please report this to your codebase's maintainers."),
1180 Timestamp = DateTimeOffset.UtcNow,
1186 #pragma warning restore CA1502
1188#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, IOptionsMonitor< GeneralConfiguration > generalConfigurationOptions, ChatBot chatBot)
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 IOptionsMonitor< GeneralConfiguration > generalConfigurationOptions
The GeneralConfiguration IOptionsMonitor<TOptions> 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.
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.