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