tgstation-server 6.19.0
The /tg/station 13 server suite
Loading...
Searching...
No Matches
DiscordProvider.cs
Go to the documentation of this file.
1using System;
2using System.Collections.Concurrent;
3using System.Collections.Generic;
4using System.Drawing;
5using System.Globalization;
6using System.Linq;
7using System.Threading;
8using System.Threading.Tasks;
9
10using Microsoft.Extensions.DependencyInjection;
11using Microsoft.Extensions.Logging;
12using Microsoft.Extensions.Options;
13
14using 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;
24using Remora.Results;
25
36
38{
42 #pragma warning disable CA1506
44 {
46 public override bool Connected => gatewayTask?.IsCompleted == false;
47
49 public override string BotMention
50 {
51 get
52 {
53 if (!Connected)
54 throw new InvalidOperationException("Provider not connected");
55 return NormalizeMentions($"<@{currentUserId}>");
56 }
57 }
58
62 static readonly ChannelType[] SupportedGuildChannelTypes =
63 [
64 ChannelType.GuildText,
65 ChannelType.GuildAnnouncement,
66 ChannelType.PrivateThread,
67 ChannelType.PublicThread,
68 ];
69
74
78 readonly IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions;
79
83 readonly ServiceProvider serviceProvider;
84
88 readonly List<ulong> mappedChannels;
89
93 readonly object connectDisconnectLock;
94
98 readonly bool deploymentBranding;
99
104
108 CancellationTokenSource? gatewayCts;
109
113 TaskCompletionSource? gatewayReadyTcs;
114
118 Task<Result>? gatewayTask;
119
123 Snowflake currentUserId;
124
129
135 static string NormalizeMentions(string fromDiscord) => fromDiscord.Replace("<@!", "<@", StringComparison.Ordinal);
136
148 IAsyncDelayer asyncDelayer,
149 ILogger<DiscordProvider> logger,
151 IOptionsMonitor<GeneralConfiguration> generalConfigurationOptions,
152 ChatBot chatBot)
153 : base(jobManager, asyncDelayer, logger, chatBot)
154 {
155 this.assemblyInformationProvider = assemblyInformationProvider ?? throw new ArgumentNullException(nameof(assemblyInformationProvider));
156 this.generalConfigurationOptions = generalConfigurationOptions ?? throw new ArgumentNullException(nameof(generalConfigurationOptions));
157
158 mappedChannels = new List<ulong>();
159 connectDisconnectLock = new object();
160
161 var csb = new DiscordConnectionStringBuilder(chatBot.ConnectionString!);
162 var botToken = csb.BotToken!;
163 outputDisplayType = csb.DMOutputDisplay;
164 deploymentBranding = csb.DeploymentBranding;
165
166 serviceProvider = new ServiceCollection()
167 .AddDiscordGateway(serviceProvider => botToken)
168 .Configure<DiscordGatewayClientOptions>(options => options.Intents |= GatewayIntents.MessageContents)
169 .AddSingleton(serviceProvider => (IDiscordResponders)this)
170 .AddResponder<DiscordForwardingResponder>()
171 .BuildServiceProvider();
172 }
173
175 public override async ValueTask DisposeAsync()
176 {
177 lock (serviceProvider)
178 {
179 // serviceProvider can recursively dispose us
180 if (disposing)
181 return;
182 disposing = true;
183 }
184
185 await base.DisposeAsync();
186
187 await serviceProvider.DisposeAsync();
188
189 Logger.LogTrace("ServiceProvider disposed");
190
191 // this line is purely here to shutup CA2213. It should always be null
192 gatewayCts?.Dispose();
193
194 disposing = false;
195 }
196
198 public override async ValueTask SendMessage(Message? replyTo, MessageContent message, ulong channelId, CancellationToken cancellationToken)
199 {
200 ArgumentNullException.ThrowIfNull(message);
201
202 Optional<IMessageReference> replyToReference = default;
203 Optional<IAllowedMentions> allowedMentions = default;
204 if (replyTo != null && replyTo is DiscordMessage discordMessage)
205 {
206 replyToReference = discordMessage.MessageReference;
207 allowedMentions = new AllowedMentions(
208 Parse: new List<MentionType> // reset settings back to how discord acts if this is not passed (which is different than the default if empty)
209 {
210 MentionType.Everyone,
211 MentionType.Roles,
212 MentionType.Users,
213 },
214 MentionRepliedUser: false); // disable reply mentions
215 }
216
217 var embeds = ConvertEmbed(message.Embed);
218
219 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
220 async ValueTask SendToChannel(Snowflake channelId)
221 {
222 if (message.Text == null)
223 {
224 Logger.LogWarning(
225 "Failed to send to channel {channelId}: Message was null!",
226 channelId);
227
228 await channelsClient.CreateMessageAsync(
229 channelId,
230 "TGS: Could not send message to Discord. Message was `null`!",
231 messageReference: replyToReference,
232 allowedMentions: allowedMentions,
233 ct: cancellationToken);
234
235 return;
236 }
237
238 var result = await channelsClient.CreateMessageAsync(
239 channelId,
240 message.Text,
241 embeds: embeds,
242 messageReference: replyToReference,
243 allowedMentions: allowedMentions,
244 ct: cancellationToken);
245
246 if (!result.IsSuccess)
247 {
248 Logger.LogWarning(
249 "Failed to send to channel {channelId}: {result}",
250 channelId,
251 result.LogFormat());
252
253 if (result.Error is RestResultError<RestError> restError && restError.Error.Code == DiscordError.InvalidFormBody)
254 await channelsClient.CreateMessageAsync(
255 channelId,
256 "TGS: Could not send message to Discord. Body was malformed or too long",
257 messageReference: replyToReference,
258 allowedMentions: allowedMentions,
259 ct: cancellationToken);
260 }
261 }
262
263 try
264 {
265 if (channelId == 0)
266 {
267 IEnumerable<IChannel> unmappedTextChannels;
268 var allAccessibleTextChannels = await GetAllAccessibleTextChannels(cancellationToken);
269 lock (mappedChannels)
270 {
271 unmappedTextChannels = allAccessibleTextChannels
272 .Where(x => !mappedChannels.Contains(x.ID.Value))
273 .ToList();
274
275 var remapRequired = unmappedTextChannels.Any()
276 || mappedChannels.Any(
277 mappedChannel => !allAccessibleTextChannels.Any(
278 accessibleTextChannel => accessibleTextChannel.ID == new Snowflake(mappedChannel)));
279
280 if (remapRequired)
281 EnqueueMessage(null);
282 }
283
284 // discord API confirmed weak boned: https://stackoverflow.com/a/52462336
285 if (unmappedTextChannels.Any())
286 {
287 Logger.LogDebug("Dispatching to {count} unmapped channels...", unmappedTextChannels.Count());
289 unmappedTextChannels.Select(
290 x => SendToChannel(x.ID)));
291 }
292
293 return;
294 }
295
296 await SendToChannel(new Snowflake(channelId));
297 }
298 catch (Exception e) when (e is not OperationCanceledException)
299 {
300 Logger.LogWarning(e, "Error sending discord message!");
301 }
302 }
303
305 public override async ValueTask<Func<string?, string, ValueTask<Func<bool, ValueTask>>>> SendUpdateMessage(
306 Models.RevisionInformation revisionInformation,
307 Models.RevisionInformation? previousRevisionInformation,
308 EngineVersion engineVersion,
309 DateTimeOffset? estimatedCompletionTime,
310 string? gitHubOwner,
311 string? gitHubRepo,
312 ulong channelId,
313 bool localCommitPushed,
314 CancellationToken cancellationToken)
315 {
316 ArgumentNullException.ThrowIfNull(revisionInformation);
317 ArgumentNullException.ThrowIfNull(engineVersion);
318
319 localCommitPushed |= revisionInformation.CommitSha == revisionInformation.OriginCommitSha;
320
321 var fields = BuildUpdateEmbedFields(revisionInformation, previousRevisionInformation, engineVersion, gitHubOwner, gitHubRepo, localCommitPushed);
322 Optional<IEmbedAuthor> author = new EmbedAuthor(assemblyInformationProvider.VersionPrefix)
323 {
324 Url = "https://github.com/tgstation/tgstation-server",
325 IconUrl = "https://cdn.discordapp.com/attachments/1114451486374637629/1151650846019432448/tgs.png", // 404's in browsers but works in Discord
326 };
327 var embed = new Embed
328 {
329 Author = deploymentBranding ? author : default,
330 Colour = Color.FromArgb(0xF1, 0xC4, 0x0F),
331 Description = "TGS has begun deploying active repository code to production.",
332 Fields = fields,
333 Title = "Code Deployment",
334 Footer = new EmbedFooter(
335 $"In progress...{(estimatedCompletionTime.HasValue ? " ETA" : String.Empty)}"),
336 Timestamp = estimatedCompletionTime ?? default,
337 };
338
339 Logger.LogTrace("Attempting to post deploy embed to channel {channelId}...", channelId);
340 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
341
342 var prefix = GetEngineCompilerPrefix(engineVersion.Engine!.Value);
343 var messageResponse = await channelsClient.CreateMessageAsync(
344 new Snowflake(channelId),
345 $"{prefix}: Deployment in progress...",
346 embeds: new List<IEmbed> { embed },
347 ct: cancellationToken);
348
349 if (!messageResponse.IsSuccess)
350 Logger.LogWarning("Failed to post deploy embed to channel {channelId}: {result}", channelId, messageResponse.LogFormat());
351
352 return async (errorMessage, dreamMakerOutput) =>
353 {
354 var completionString = errorMessage == null ? "Pending" : "Failed";
355
356 Embed CreateUpdatedEmbed(string message, Color color) => new()
357 {
358 Author = embed.Author,
359 Colour = color,
360 Description = message,
361 Fields = fields,
362 Title = embed.Title,
363 Footer = new EmbedFooter(
364 completionString),
365 Timestamp = DateTimeOffset.UtcNow,
366 };
367
368 if (errorMessage == null)
369 embed = CreateUpdatedEmbed(
370 "The deployment completed successfully and will be available at the next server reboot.",
371 Color.Blue);
372 else
373 embed = CreateUpdatedEmbed(
374 "The deployment failed.",
375 Color.Red);
376
377 var showDMOutput = outputDisplayType switch
378 {
379 DiscordDMOutputDisplayType.Always => true,
380 DiscordDMOutputDisplayType.Never => false,
381 DiscordDMOutputDisplayType.OnError => errorMessage != null,
382 _ => throw new InvalidOperationException($"Invalid DiscordDMOutputDisplayType: {outputDisplayType}"),
383 };
384
385 if (dreamMakerOutput != null)
386 {
387 // https://github.com/discord-net/Discord.Net/blob/8349cd7e1eb92e9a3baff68082c30a7b43e8e9b7/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs#L431
388 const int MaxFieldValueLength = 1024;
389 showDMOutput = showDMOutput && dreamMakerOutput.Length < MaxFieldValueLength - (6 + Environment.NewLine.Length);
390 if (showDMOutput)
391 fields.Add(new EmbedField(
392 "Compiler Output",
393 $"```{Environment.NewLine}{dreamMakerOutput}{Environment.NewLine}```",
394 false));
395 }
396
397 if (errorMessage != null)
398 fields.Add(new EmbedField(
399 "Error Message",
400 errorMessage,
401 false));
402
403 var updatedMessageText = errorMessage == null ? $"{prefix}: Deployment pending reboot..." : $"{prefix}: Deployment failed!";
404
405 IMessage? updatedMessage = null;
406 async ValueTask CreateUpdatedMessage()
407 {
408 var createUpdatedMessageResponse = await channelsClient.CreateMessageAsync(
409 new Snowflake(channelId),
410 updatedMessageText,
411 embeds: new List<IEmbed> { embed },
412 ct: cancellationToken);
413
414 if (!createUpdatedMessageResponse.IsSuccess)
415 Logger.LogWarning(
416 "Creating updated deploy embed failed: {result}",
417 createUpdatedMessageResponse.LogFormat());
418 else
419 updatedMessage = createUpdatedMessageResponse.Entity;
420 }
421
422 if (!messageResponse.IsSuccess)
423 await CreateUpdatedMessage();
424 else
425 {
426 var editResponse = await channelsClient.EditMessageAsync(
427 new Snowflake(channelId),
428 messageResponse.Entity.ID,
429 updatedMessageText,
430 embeds: new List<IEmbed> { embed },
431 ct: cancellationToken);
432
433 if (!editResponse.IsSuccess)
434 {
435 Logger.LogWarning(
436 "Updating deploy embed {messageId} failed, attempting new post: {result}",
437 messageResponse.Entity.ID,
438 editResponse.LogFormat());
439 await CreateUpdatedMessage();
440 }
441 else
442 updatedMessage = editResponse.Entity;
443 }
444
445 return async (active) =>
446 {
447 if (updatedMessage == null || errorMessage != null)
448 return;
449
450 if (active)
451 {
452 completionString = "Succeeded";
453 updatedMessageText = $"{prefix}: Deployment succeeded!";
454 embed = CreateUpdatedEmbed(
455 "The deployment completed successfully and was applied to server.",
456 Color.Green);
457 }
458 else
459 {
460 completionString = "Inactive";
461 embed = CreateUpdatedEmbed(
462 "This deployment has been superceeded by a new one.",
463 Color.Gray);
464 }
465
466 var editResponse = await channelsClient.EditMessageAsync(
467 new Snowflake(channelId),
468 updatedMessage.ID,
469 updatedMessageText,
470 embeds: new List<IEmbed> { embed },
471 ct: cancellationToken);
472
473 if (!editResponse.IsSuccess)
474 Logger.LogWarning(
475 "Finalizing deploy embed {messageId} failed: {result}",
476 messageResponse.Entity.ID,
477 editResponse.LogFormat());
478 };
479 };
480 }
481
483 public async Task<Result> RespondAsync(IMessageCreate messageCreateEvent, CancellationToken cancellationToken)
484 {
485 ArgumentNullException.ThrowIfNull(messageCreateEvent);
486
487 if ((messageCreateEvent.Type != MessageType.Default
488 && messageCreateEvent.Type != MessageType.InlineReply)
489 || messageCreateEvent.Author.ID == currentUserId)
490 return Result.FromSuccess();
491
492 var messageReference = new MessageReference
493 {
494 ChannelID = messageCreateEvent.ChannelID,
495 GuildID = messageCreateEvent.GuildID,
496 MessageID = messageCreateEvent.ID,
497 FailIfNotExists = false,
498 };
499
500 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
501 var channelResponse = await channelsClient.GetChannelAsync(messageCreateEvent.ChannelID, cancellationToken);
502 if (!channelResponse.IsSuccess)
503 {
504 Logger.LogWarning(
505 "Failed to get channel {channelId} in response to message {messageId}!",
506 messageCreateEvent.ChannelID,
507 messageCreateEvent.ID);
508
509 // we'll handle the errors ourselves
510 return Result.FromSuccess();
511 }
512
513 var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM;
514 var shouldNotAnswer = !pm;
515 if (shouldNotAnswer)
516 lock (mappedChannels)
517 shouldNotAnswer = !mappedChannels.Contains(messageCreateEvent.ChannelID.Value) && !mappedChannels.Contains(0);
518
519 var content = NormalizeMentions(messageCreateEvent.Content);
520 var mentionedUs = messageCreateEvent.Mentions.Any(x => x.ID == currentUserId)
521 || (!shouldNotAnswer && content.Split(' ').First().Equals(ChatManager.CommonMention, StringComparison.OrdinalIgnoreCase));
522
523 if (shouldNotAnswer)
524 {
525 if (mentionedUs)
526 Logger.LogTrace(
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);
532
533 return Result.FromSuccess();
534 }
535
536 string guildName = "UNKNOWN";
537 if (!pm)
538 {
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;
543 else
544 Logger.LogWarning(
545 "Failed to get channel {channelID} in response to message {messageID}: {result}",
546 messageCreateEvent.ChannelID,
547 messageCreateEvent.ID,
548 messageGuildResponse.LogFormat());
549 }
550
551 var result = new DiscordMessage(
552 new ChatUser(
554 pm ? messageCreateEvent.Author.Username : guildName,
555 channelResponse.Entity.Name.Value!,
556 messageCreateEvent.ChannelID.Value)
557 {
558 IsPrivateChannel = pm,
559 EmbedsSupported = true,
560
561 // isAdmin and Tag populated by manager
562 },
563 messageCreateEvent.Author.Username,
564 NormalizeMentions($"<@{messageCreateEvent.Author.ID}>"),
565 messageCreateEvent.Author.ID.Value),
566 content,
567 messageReference);
568
569 EnqueueMessage(result);
570 return Result.FromSuccess();
571 }
572
574 public Task<Result> RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
575 {
576 ArgumentNullException.ThrowIfNull(readyEvent);
577
578 Logger.LogTrace("Gatway ready. Version: {version}", readyEvent.Version);
579 gatewayReadyTcs?.TrySetResult();
580 return Task.FromResult(Result.FromSuccess());
581 }
582
584 protected override async ValueTask Connect(CancellationToken cancellationToken)
585 {
586 try
587 {
589 {
590 if (gatewayCts != null)
591 throw new InvalidOperationException("Discord gateway still active!");
592
593 gatewayCts = new CancellationTokenSource();
594 }
595
596 var gatewayCancellationToken = gatewayCts.Token;
597 var gatewayClient = serviceProvider.GetRequiredService<DiscordGatewayClient>();
598
599 Task<Result> localGatewayTask;
600 gatewayReadyTcs = new TaskCompletionSource();
601
602 using var gatewayConnectionAbortRegistration = cancellationToken.Register(() => gatewayReadyTcs.TrySetCanceled(cancellationToken));
603 gatewayCancellationToken.Register(() => Logger.LogTrace("Stopping gateway client..."));
604
605 // reconnects keep happening until we stop or it faults, our auto-reconnector will handle the latter
606 localGatewayTask = gatewayClient.RunAsync(gatewayCancellationToken);
607 try
608 {
609 await Task.WhenAny(gatewayReadyTcs.Task, localGatewayTask);
610
611 cancellationToken.ThrowIfCancellationRequested();
612 if (localGatewayTask.IsCompleted)
613 throw new JobException(ErrorCode.ChatCannotConnectProvider);
614
615 var userClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
616
617 using var localCombinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, gatewayCancellationToken);
618 var localCombinedCancellationToken = localCombinedCts.Token;
619 var currentUserResult = await userClient.GetCurrentUserAsync(localCombinedCancellationToken);
620 if (!currentUserResult.IsSuccess)
621 {
622 localCombinedCancellationToken.ThrowIfCancellationRequested();
623 Logger.LogWarning("Unable to retrieve current user: {result}", currentUserResult.LogFormat());
624 throw new JobException(ErrorCode.ChatCannotConnectProvider);
625 }
626
627 currentUserId = currentUserResult.Entity.ID;
628 }
629 finally
630 {
631 gatewayTask = localGatewayTask;
632 }
633 }
634 catch
635 {
636 // will handle cleanup
637 // DCT: Musn't abort
638 await DisconnectImpl(CancellationToken.None);
639 throw;
640 }
641 }
642
644 protected override async ValueTask DisconnectImpl(CancellationToken cancellationToken)
645 {
646 Task<Result> localGatewayTask;
647 CancellationTokenSource localGatewayCts;
649 {
650 localGatewayTask = gatewayTask!;
651 localGatewayCts = gatewayCts!;
652 gatewayTask = null;
653 gatewayCts = null;
654 if (localGatewayTask == null)
655 return;
656 }
657
658 localGatewayCts.Cancel();
659 var gatewayResult = await localGatewayTask;
660
661 Logger.LogTrace("Gateway task complete");
662 if (!gatewayResult.IsSuccess)
663 Logger.LogWarning("Gateway issue: {result}", gatewayResult.LogFormat());
664
665 localGatewayCts.Dispose();
666 }
667
669 protected override async ValueTask<Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> MapChannelsImpl(IEnumerable<Models.ChatChannel> channels, CancellationToken cancellationToken)
670 {
671 ArgumentNullException.ThrowIfNull(channels);
672
673 var remapRequired = false;
674 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
675 var guildTasks = new ConcurrentDictionary<Snowflake, Task<Result<IGuild>>>();
676
677 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>?> GetModelChannelFromDBChannel(Models.ChatChannel channelFromDB)
678 {
679 if (!channelFromDB.DiscordChannelId.HasValue)
680 throw new InvalidOperationException("ChatChannel missing DiscordChannelId!");
681
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)
686 {
687 Logger.LogWarning(
688 "Error retrieving discord channel {channelId}: {result}",
689 channelId,
690 discordChannelResponse.LogFormat());
691
692 var remapConditional = !(discordChannelResponse.Error is RestResultError<RestError> restResultError
693 && (restResultError.Error?.Code == DiscordError.MissingAccess
694 || restResultError.Error?.Code == DiscordError.UnknownChannel));
695
696 if (remapConditional)
697 {
698 Logger.Log(
699 remapRequired
700 ? LogLevel.Trace
701 : LogLevel.Debug,
702 "Error on channel {channelId} is not an access/thread issue. Will retry remap...",
703 channelId);
704 remapRequired = true;
705 }
706
707 return null;
708 }
709
710 var channelType = discordChannelResponse.Entity.Type;
711 if (!SupportedGuildChannelTypes.Contains(channelType))
712 {
713 Logger.LogWarning("Cound not map channel {channelId}! Incorrect type: {channelType}", channelId, discordChannelResponse.Entity.Type);
714 return null;
715 }
716
717 var guildId = discordChannelResponse.Entity.GuildID.Value;
718
719 var added = false;
720 var guildsResponse = await guildTasks.GetOrAdd(
721 guildId,
722 localGuildId =>
723 {
724 added = true;
725 return guildsClient.GetGuildAsync(
726 localGuildId,
727 false,
728 cancellationToken);
729 });
730 if (!guildsResponse.IsSuccess)
731 {
732 if (added)
733 {
734 Logger.LogWarning(
735 "Error retrieving discord guild {guildID}: {result}",
736 guildId,
737 guildsResponse.LogFormat());
738 remapRequired = true;
739 }
740
741 return null;
742 }
743
744 var connectionName = guildsResponse.Entity.Name;
745
746 var channelModel = new ChannelRepresentation(
747 guildsResponse.Entity.Name,
748 discordChannelResponse.Entity.Name.Value!,
749 channelId)
750 {
751 IsAdminChannel = channelFromDB.IsAdminChannel == true,
752 IsPrivateChannel = false,
753 Tag = channelFromDB.Tag,
754 EmbedsSupported = true,
755 };
756
757 Logger.LogTrace("Mapped channel {realId}: {friendlyName}", channelModel.RealId, channelModel.FriendlyName);
758 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
759 channelFromDB,
760 new List<ChannelRepresentation> { channelModel });
761 }
762
763 var tasks = channels
764 .Where(x => x.DiscordChannelId != 0)
765 .Select(GetModelChannelFromDBChannel);
766
767 var channelTuples = await ValueTaskExtensions.WhenAll(tasks.ToList());
768
769 var list = channelTuples
770 .Where(x => x != null)
771 .Cast<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>() // NRT my beloathed
772 .ToList();
773
774 var channelIdZeroModel = channels.FirstOrDefault(x => x.DiscordChannelId == 0);
775 if (channelIdZeroModel != null)
776 {
777 Logger.LogInformation("Mapping ALL additional accessible text channels");
778 var allAccessibleChannels = await GetAllAccessibleTextChannels(cancellationToken);
779 var unmappedTextChannels = allAccessibleChannels
780 .Where(x => !tasks.Any(task => task.Result != null && new Snowflake(task.Result.Item1.DiscordChannelId!.Value) == x.ID));
781
782 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> CreateMappingsForUnmappedChannels()
783 {
784 var unmappedTasks =
785 unmappedTextChannels.Select(
786 async unmappedTextChannel =>
787 {
788 var fakeChannelModel = new Models.ChatChannel
789 {
790 DiscordChannelId = unmappedTextChannel.ID.Value,
791 IsAdminChannel = channelIdZeroModel.IsAdminChannel,
792 Tag = channelIdZeroModel.Tag,
793 };
794
795 var tuple = await GetModelChannelFromDBChannel(fakeChannelModel);
796 return tuple?.Item2.First();
797 })
798 .ToList();
799
800 // Add catch-all channel
801 unmappedTasks.Add(Task.FromResult<ChannelRepresentation?>(
803 "(Unknown Discord Guilds)",
804 "(Unknown Discord Channels)",
805 0)
806 {
807 IsAdminChannel = channelIdZeroModel.IsAdminChannel!.Value,
808 EmbedsSupported = true,
809 Tag = channelIdZeroModel.Tag,
810 }));
811
812 await Task.WhenAll(unmappedTasks);
813 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
814 channelIdZeroModel,
815 unmappedTasks
816 .Select(x => x.Result)
817 .Where(x => x != null)
818 .Cast<ChannelRepresentation>() // NRT my beloathed
819 .ToList());
820 }
821
822 var task = CreateMappingsForUnmappedChannels();
823 var tuple = await task;
824 list.Add(tuple);
825 }
826
827 lock (mappedChannels)
828 {
829 mappedChannels.Clear();
830 mappedChannels.AddRange(list.SelectMany(x => x.Item2).Select(x => x.RealId));
831 }
832
833 if (remapRequired)
834 {
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.");
836 EnqueueMessage(null);
837 }
838
839 return new Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(list.Select(x => new KeyValuePair<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(x.Item1, x.Item2)));
840 }
841
847 async ValueTask<IEnumerable<IChannel>> GetAllAccessibleTextChannels(CancellationToken cancellationToken)
848 {
849 var usersClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
850 var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken);
851 if (!currentGuildsResponse.IsSuccess)
852 {
853 Logger.LogWarning(
854 "Error retrieving current discord guilds: {result}",
855 currentGuildsResponse.LogFormat());
856 return Enumerable.Empty<IChannel>();
857 }
858
859 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
860
861 async ValueTask<IEnumerable<IChannel>> GetGuildChannels(IPartialGuild guild)
862 {
863 var channelsTask = guildsClient.GetGuildChannelsAsync(guild.ID.Value, cancellationToken);
864 var threads = await guildsClient.ListActiveGuildThreadsAsync(guild.ID.Value, cancellationToken);
865 if (!threads.IsSuccess)
866 Logger.LogWarning(
867 "Error retrieving discord guild threads {guildId} ({guildName}): {result}",
868 guild.ID,
869 guild.Name,
870 threads.LogFormat());
871
872 var channels = await channelsTask;
873 if (!channels.IsSuccess)
874 Logger.LogWarning(
875 "Error retrieving discord guild channels {guildId} ({guildName}): {result}",
876 guild.ID,
877 guild.Name,
878 channels.LogFormat());
879
880 if (!channels.IsSuccess && !threads.IsSuccess)
881 return Enumerable.Empty<IChannel>();
882
883 if (channels.IsSuccess && threads.IsSuccess)
884 return channels.Entity.Concat(threads.Entity.Threads ?? Enumerable.Empty<IChannel>());
885
886 return channels.Entity ?? threads.Entity?.Threads ?? Enumerable.Empty<IChannel>();
887 }
888
889 var guildsChannelsTasks = currentGuildsResponse.Entity
890 .Select(GetGuildChannels);
891
892 var guildsChannels = await ValueTaskExtensions.WhenAll(guildsChannelsTasks, currentGuildsResponse.Entity.Count);
893
894 var allAccessibleChannels = guildsChannels
895 .SelectMany(channels => channels)
896 .Where(guildChannel => SupportedGuildChannelTypes.Contains(guildChannel.Type));
897
898 return allAccessibleChannels;
899 }
900
911 List<IEmbedField> BuildUpdateEmbedFields(
912 Models.RevisionInformation revisionInformation,
913 Models.RevisionInformation? previousRevisionInformation,
914 EngineVersion engineVersion,
915 string? gitHubOwner,
916 string? gitHubRepo,
917 bool localCommitPushed)
918 {
919 bool gitHub = gitHubOwner != null && gitHubRepo != null;
920 var engineField = engineVersion.Engine!.Value switch
921 {
922 EngineType.Byond => new EmbedField(
923 "BYOND Version",
924 $"{engineVersion.Version!.Major}.{engineVersion.Version.Minor}{(engineVersion.CustomIteration.HasValue ? $".{engineVersion.CustomIteration.Value}" : String.Empty)}",
925 true),
926 EngineType.OpenDream => new EmbedField(
927 "OpenDream Version",
928 $"[{engineVersion.SourceSHA![..7]}]({generalConfigurationOptions.CurrentValue.OpenDreamGitUrl}/commit/{engineVersion.SourceSHA})",
929 true),
930 _ => throw new InvalidOperationException($"Invaild EngineType: {engineVersion.Engine.Value}"),
931 };
932
933 var revisionSha = revisionInformation.CommitSha!;
934 var revisionOriginSha = revisionInformation.OriginCommitSha!;
935 var fields = new List<IEmbedField>
936 {
937 engineField,
938 };
939
940 if (gitHubOwner == null || gitHubRepo == null)
941 return fields;
942
943 var previousTestMerges = (IEnumerable<RevInfoTestMerge>?)previousRevisionInformation?.ActiveTestMerges ?? Enumerable.Empty<RevInfoTestMerge>();
944 var currentTestMerges = (IEnumerable<RevInfoTestMerge>?)revisionInformation.ActiveTestMerges ?? Enumerable.Empty<RevInfoTestMerge>();
945
946 // determine what TMs were changed and how
947 var addedTestMerges = currentTestMerges
948 .Select(x => x.TestMerge)
949 .Where(x => !previousTestMerges
950 .Any(y => y.TestMerge.Number == x.Number))
951 .ToList();
952 var removedTestMerges = previousTestMerges
953 .Select(x => x.TestMerge)
954 .Where(x => !currentTestMerges
955 .Any(y => y.TestMerge.Number == x.Number))
956 .ToList();
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))
961 .ToList();
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))
966 .ToList();
967
968 fields.Add(
969 new EmbedField(
970 "Local Commit",
971 localCommitPushed && gitHub
972 ? $"[{revisionSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionSha})"
973 : revisionSha[..7],
974 true));
975
976 fields.Add(
977 new EmbedField(
978 "Branch Commit",
979 gitHub
980 ? $"[{revisionOriginSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionOriginSha})"
981 : revisionOriginSha[..7],
982 true));
983
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}**_")}",
988 false)));
989
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}**_")}",
994 false)));
995
996 fields.AddRange(unchangedTestMerges
997 .Select(x => new EmbedField(
998 $"#{x.Number}",
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}**_")}",
1000 false)));
1001
1002 if (removedTestMerges.Count != 0)
1003 fields.Add(
1004 new EmbedField(
1005 "Removed:",
1006 String.Join(
1007 Environment.NewLine,
1008 removedTestMerges
1009 .Select(x => $"- #{x.Number} [{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_"))));
1010
1011 return fields;
1012 }
1013
1019#pragma warning disable CA1502
1020 Optional<IReadOnlyList<IEmbed>> ConvertEmbed(ChatEmbed? embed)
1021 {
1022 if (embed == null)
1023 return default;
1024
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);
1030 else
1031 embedErrors.Add(
1032 String.Format(
1033 CultureInfo.InvariantCulture,
1034 "Invalid embed colour: {0}",
1035 embed.Colour));
1036
1037 if (embed.Author != null && String.IsNullOrWhiteSpace(embed.Author.Name))
1038 {
1039 embedErrors.Add("Null or whitespace embed author name!");
1040 embed.Author = null;
1041 }
1042
1043 List<IEmbedField>? fields = null;
1044 if (embed.Fields != null)
1045 {
1046 fields = new List<IEmbedField>();
1047 var i = -1;
1048 foreach (var field in embed.Fields)
1049 {
1050 ++i;
1051 var invalid = false;
1052 if (String.IsNullOrWhiteSpace(field.Name))
1053 {
1054 embedErrors.Add(
1055 String.Format(
1056 CultureInfo.InvariantCulture,
1057 "Null or whitespace field name at index {0}!",
1058 i));
1059 invalid = true;
1060 }
1061
1062 if (String.IsNullOrWhiteSpace(field.Value))
1063 {
1064 embedErrors.Add(
1065 String.Format(
1066 CultureInfo.InvariantCulture,
1067 "Null or whitespace field value at index {0}!",
1068 i));
1069 invalid = true;
1070 }
1071
1072 if (invalid)
1073 continue;
1074
1075 fields.Add(new EmbedField(field.Name!, field.Value!)
1076 {
1077 IsInline = field.IsInline ?? default(Optional<bool>),
1078 });
1079 }
1080 }
1081
1082 if (embed.Footer != null && String.IsNullOrWhiteSpace(embed.Footer.Text))
1083 {
1084 embedErrors.Add("Null or whitespace embed footer text!");
1085 embed.Footer = null;
1086 }
1087
1088 if (embed.Image != null && String.IsNullOrWhiteSpace(embed.Image.Url))
1089 {
1090 embedErrors.Add("Null or whitespace embed image url!");
1091 embed.Image = null;
1092 }
1093
1094 if (embed.Thumbnail != null && String.IsNullOrWhiteSpace(embed.Thumbnail.Url))
1095 {
1096 embedErrors.Add("Null or whitespace embed thumbnail url!");
1097 embed.Thumbnail = null;
1098 }
1099
1100 Optional<DateTimeOffset> timestampOptional = default;
1101 if (embed.Timestamp != null)
1102 if (DateTimeOffset.TryParse(embed.Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var timestamp))
1103 timestampOptional = timestamp.ToUniversalTime();
1104 else
1105 embedErrors.Add(
1106 String.Format(
1107 CultureInfo.InvariantCulture,
1108 "Invalid embed timestamp: {0}",
1109 embed.Timestamp));
1110
1111 var discordEmbed = new Embed
1112 {
1113 Author = embed.Author != null
1114 ? new EmbedAuthor(embed.Author.Name!)
1115 {
1116 IconUrl = embed.Author.IconUrl ?? default(Optional<string>),
1117 ProxyIconUrl = embed.Author.ProxyIconUrl ?? default(Optional<string>),
1118 Url = embed.Author.Url ?? default(Optional<string>),
1119 }
1120 : default(Optional<IEmbedAuthor>),
1121 Colour = colour,
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!)
1126 {
1127 IconUrl = embed.Footer.IconUrl ?? default(Optional<string>),
1128 ProxyIconUrl = embed.Footer.ProxyIconUrl ?? default(Optional<string>),
1129 }
1130 : default,
1131 Image = embed.Image != null
1132 ? new EmbedImage(embed.Image.Url!)
1133 {
1134 Width = embed.Image.Width ?? default(Optional<int>),
1135 Height = embed.Image.Height ?? default(Optional<int>),
1136 ProxyUrl = embed.Image.ProxyUrl ?? default(Optional<string>),
1137 }
1138 : default(Optional<IEmbedImage>),
1139 Provider = embed.Provider != null
1140 ? new EmbedProvider
1141 {
1142 Name = embed.Provider.Name ?? default(Optional<string>),
1143 Url = embed.Provider.Url ?? default(Optional<string>),
1144 }
1145 : default(Optional<IEmbedProvider>),
1146 Thumbnail = embed.Thumbnail != null
1147 ? new EmbedThumbnail(embed.Thumbnail.Url!)
1148 {
1149 Width = embed.Thumbnail.Width ?? default(Optional<int>),
1150 Height = embed.Thumbnail.Height ?? default(Optional<int>),
1151 ProxyUrl = embed.Thumbnail.ProxyUrl ?? default(Optional<string>),
1152 }
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
1158 ? new EmbedVideo
1159 {
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>),
1164 }
1165 : default(Optional<IEmbedVideo>),
1166 };
1167
1168 var result = new List<IEmbed> { discordEmbed };
1169
1170 if (embedErrors.Count > 0)
1171 {
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
1175 {
1176 Title = "TGS Embed Errors",
1177 Description = joinedErrors,
1178 Colour = Color.Red,
1179 Footer = new EmbedFooter("Please report this to your codebase's maintainers."),
1180 Timestamp = DateTimeOffset.UtcNow,
1181 });
1182 }
1183
1184 return result;
1185 }
1186 #pragma warning restore CA1502
1187 }
1188#pragma warning restore CA1506
1189}
Optional< IReadOnlyList< IEmbed > > ConvertEmbed(ChatEmbed? embed)
Convert a ChatEmbed to an IEmbed parameters.
ChatConnectionStringBuilder for ChatProvider.Discord.
Information about an engine installation.
int? CustomIteration
The revision of the custom build.
string? ConnectionString
The information used to connect to the Provider.
bool? IsAdminChannel
If the ChatChannel is an admin channel.
Extension methods for the ValueTask and ValueTask<TResult> classes.
static async ValueTask WhenAll(IEnumerable< ValueTask > tasks)
Fully await a given list of tasks .
bool IsAdminChannel
If this is considered a channel for admin commands.
const string CommonMention
The common bot mention.
Represents a tgs_chat_user datum.
Definition ChatUser.cs:12
A Message containing the source IMessageReference.
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.
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.
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.
Definition Message.cs:9
void EnqueueMessage(Message? message)
Queues a message for NextMessage(CancellationToken).
Definition Provider.cs:239
static string GetEngineCompilerPrefix(Api.Models.EngineType engineType)
Get the prefix for messages about deployments.
readonly IJobManager jobManager
The IJobManager for the Provider.
Definition Provider.cs:40
ILogger< Provider > Logger
The ILogger for the Provider.
Definition Provider.cs:35
Represents an embed for the chat.
Definition ChatEmbed.cs:9
string? Timestamp
The ISO 8601 timestamp of the embed.
Definition ChatEmbed.cs:30
ChatEmbedMedia? Image
The ChatEmbedMedia for an image.
Definition ChatEmbed.cs:45
ChatEmbedFooter? Footer
The ChatEmbedFooter.
Definition ChatEmbed.cs:40
ChatEmbedMedia? Thumbnail
The ChatEmbedMedia for a thumbnail.
Definition ChatEmbed.cs:50
ICollection< ChatEmbedField >? Fields
The ChatEmbedFields.
Definition ChatEmbed.cs:70
string? Colour
The colour of the embed in the format hex "#AARRGGBB".
Definition ChatEmbed.cs:35
ChatEmbedAuthor? Author
The ChatEmbedAuthor.
Definition ChatEmbed.cs:65
string? Url
Gets the source URL of the media. Only supports http(s) and attachments.
Represents a message to send to a chat provider.
Operation exceptions thrown from the context of a Models.Job.
Many to many relationship for Models.RevisionInformation and Models.TestMerge.
Combined interface for the IResponder types used by TGS.
Manages the runtime of Jobs.
ErrorCode
Types of Response.ErrorMessageResponses that the API may return.
Definition ErrorCode.cs:12
EngineType
The type of engine the codebase is using.
Definition EngineType.cs:7
@ Optional
DMAPI validation is performed but not required for the deployment to succeed.
DiscordDMOutputDisplayType
When the DM output section of Discord deployment embeds should be shown.