tgstation-server 6.16.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
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
73
78
82 readonly ServiceProvider serviceProvider;
83
87 readonly List<ulong> mappedChannels;
88
92 readonly object connectDisconnectLock;
93
97 readonly bool deploymentBranding;
98
103
107 CancellationTokenSource? gatewayCts;
108
112 TaskCompletionSource? gatewayReadyTcs;
113
117 Task<Result>? gatewayTask;
118
122 Snowflake currentUserId;
123
128
134 static string NormalizeMentions(string fromDiscord) => fromDiscord.Replace("<@!", "<@", StringComparison.Ordinal);
135
147 IAsyncDelayer asyncDelayer,
148 ILogger<DiscordProvider> logger,
150 ChatBot chatBot,
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 Models.RevisionInformation? previousRevisionInformation,
307 EngineVersion engineVersion,
308 DateTimeOffset? estimatedCompletionTime,
309 string? gitHubOwner,
310 string? gitHubRepo,
311 ulong channelId,
312 bool localCommitPushed,
313 CancellationToken cancellationToken)
314 {
315 ArgumentNullException.ThrowIfNull(revisionInformation);
316 ArgumentNullException.ThrowIfNull(engineVersion);
317
318 localCommitPushed |= revisionInformation.CommitSha == revisionInformation.OriginCommitSha;
319
320 var fields = BuildUpdateEmbedFields(revisionInformation, previousRevisionInformation, engineVersion, gitHubOwner, gitHubRepo, localCommitPushed);
321 Optional<IEmbedAuthor> author = new EmbedAuthor(assemblyInformationProvider.VersionPrefix)
322 {
323 Url = "https://github.com/tgstation/tgstation-server",
324 IconUrl = "https://cdn.discordapp.com/attachments/1114451486374637629/1151650846019432448/tgs.png", // 404's in browsers but works in Discord
325 };
326 var embed = new Embed
327 {
328 Author = deploymentBranding ? author : default,
329 Colour = Color.FromArgb(0xF1, 0xC4, 0x0F),
330 Description = "TGS has begun deploying active repository code to production.",
331 Fields = fields,
332 Title = "Code Deployment",
333 Footer = new EmbedFooter(
334 $"In progress...{(estimatedCompletionTime.HasValue ? " ETA" : String.Empty)}"),
335 Timestamp = estimatedCompletionTime ?? default,
336 };
337
338 Logger.LogTrace("Attempting to post deploy embed to channel {channelId}...", channelId);
339 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
340
341 var prefix = GetEngineCompilerPrefix(engineVersion.Engine!.Value);
342 var messageResponse = await channelsClient.CreateMessageAsync(
343 new Snowflake(channelId),
344 $"{prefix}: Deployment in progress...",
345 embeds: new List<IEmbed> { embed },
346 ct: cancellationToken);
347
348 if (!messageResponse.IsSuccess)
349 Logger.LogWarning("Failed to post deploy embed to channel {channelId}: {result}", channelId, messageResponse.LogFormat());
350
351 return async (errorMessage, dreamMakerOutput) =>
352 {
353 var completionString = errorMessage == null ? "Pending" : "Failed";
354
355 Embed CreateUpdatedEmbed(string message, Color color) => new()
356 {
357 Author = embed.Author,
358 Colour = color,
359 Description = message,
360 Fields = fields,
361 Title = embed.Title,
362 Footer = new EmbedFooter(
363 completionString),
364 Timestamp = DateTimeOffset.UtcNow,
365 };
366
367 if (errorMessage == null)
368 embed = CreateUpdatedEmbed(
369 "The deployment completed successfully and will be available at the next server reboot.",
370 Color.Blue);
371 else
372 embed = CreateUpdatedEmbed(
373 "The deployment failed.",
374 Color.Red);
375
376 var showDMOutput = outputDisplayType switch
377 {
378 DiscordDMOutputDisplayType.Always => true,
379 DiscordDMOutputDisplayType.Never => false,
380 DiscordDMOutputDisplayType.OnError => errorMessage != null,
381 _ => throw new InvalidOperationException($"Invalid DiscordDMOutputDisplayType: {outputDisplayType}"),
382 };
383
384 if (dreamMakerOutput != null)
385 {
386 // https://github.com/discord-net/Discord.Net/blob/8349cd7e1eb92e9a3baff68082c30a7b43e8e9b7/src/Discord.Net.Core/Entities/Messages/EmbedBuilder.cs#L431
387 const int MaxFieldValueLength = 1024;
388 showDMOutput = showDMOutput && dreamMakerOutput.Length < MaxFieldValueLength - (6 + Environment.NewLine.Length);
389 if (showDMOutput)
390 fields.Add(new EmbedField(
391 "Compiler Output",
392 $"```{Environment.NewLine}{dreamMakerOutput}{Environment.NewLine}```",
393 false));
394 }
395
396 if (errorMessage != null)
397 fields.Add(new EmbedField(
398 "Error Message",
399 errorMessage,
400 false));
401
402 var updatedMessageText = errorMessage == null ? $"{prefix}: Deployment pending reboot..." : $"{prefix}: Deployment failed!";
403
404 IMessage? updatedMessage = null;
405 async ValueTask CreateUpdatedMessage()
406 {
407 var createUpdatedMessageResponse = await channelsClient.CreateMessageAsync(
408 new Snowflake(channelId),
409 updatedMessageText,
410 embeds: new List<IEmbed> { embed },
411 ct: cancellationToken);
412
413 if (!createUpdatedMessageResponse.IsSuccess)
414 Logger.LogWarning(
415 "Creating updated deploy embed failed: {result}",
416 createUpdatedMessageResponse.LogFormat());
417 else
418 updatedMessage = createUpdatedMessageResponse.Entity;
419 }
420
421 if (!messageResponse.IsSuccess)
422 await CreateUpdatedMessage();
423 else
424 {
425 var editResponse = await channelsClient.EditMessageAsync(
426 new Snowflake(channelId),
427 messageResponse.Entity.ID,
428 updatedMessageText,
429 embeds: new List<IEmbed> { embed },
430 ct: cancellationToken);
431
432 if (!editResponse.IsSuccess)
433 {
434 Logger.LogWarning(
435 "Updating deploy embed {messageId} failed, attempting new post: {result}",
436 messageResponse.Entity.ID,
437 editResponse.LogFormat());
438 await CreateUpdatedMessage();
439 }
440 else
441 updatedMessage = editResponse.Entity;
442 }
443
444 return async (active) =>
445 {
446 if (updatedMessage == null || errorMessage != null)
447 return;
448
449 if (active)
450 {
451 completionString = "Succeeded";
452 updatedMessageText = $"{prefix}: Deployment succeeded!";
453 embed = CreateUpdatedEmbed(
454 "The deployment completed successfully and was applied to server.",
455 Color.Green);
456 }
457 else
458 {
459 completionString = "Inactive";
460 embed = CreateUpdatedEmbed(
461 "This deployment has been superceeded by a new one.",
462 Color.Gray);
463 }
464
465 var editResponse = await channelsClient.EditMessageAsync(
466 new Snowflake(channelId),
467 updatedMessage.ID,
468 updatedMessageText,
469 embeds: new List<IEmbed> { embed },
470 ct: cancellationToken);
471
472 if (!editResponse.IsSuccess)
473 Logger.LogWarning(
474 "Finalizing deploy embed {messageId} failed: {result}",
475 messageResponse.Entity.ID,
476 editResponse.LogFormat());
477 };
478 };
479 }
480
482 public async Task<Result> RespondAsync(IMessageCreate messageCreateEvent, CancellationToken cancellationToken)
483 {
484 ArgumentNullException.ThrowIfNull(messageCreateEvent);
485
486 if ((messageCreateEvent.Type != MessageType.Default
487 && messageCreateEvent.Type != MessageType.InlineReply)
488 || messageCreateEvent.Author.ID == currentUserId)
489 return Result.FromSuccess();
490
491 var messageReference = new MessageReference
492 {
493 ChannelID = messageCreateEvent.ChannelID,
494 GuildID = messageCreateEvent.GuildID,
495 MessageID = messageCreateEvent.ID,
496 FailIfNotExists = false,
497 };
498
499 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
500 var channelResponse = await channelsClient.GetChannelAsync(messageCreateEvent.ChannelID, cancellationToken);
501 if (!channelResponse.IsSuccess)
502 {
503 Logger.LogWarning(
504 "Failed to get channel {channelId} in response to message {messageId}!",
505 messageCreateEvent.ChannelID,
506 messageCreateEvent.ID);
507
508 // we'll handle the errors ourselves
509 return Result.FromSuccess();
510 }
511
512 var pm = channelResponse.Entity.Type == ChannelType.DM || channelResponse.Entity.Type == ChannelType.GroupDM;
513 var shouldNotAnswer = !pm;
514 if (shouldNotAnswer)
515 lock (mappedChannels)
516 shouldNotAnswer = !mappedChannels.Contains(messageCreateEvent.ChannelID.Value) && !mappedChannels.Contains(0);
517
518 var content = NormalizeMentions(messageCreateEvent.Content);
519 var mentionedUs = messageCreateEvent.Mentions.Any(x => x.ID == currentUserId)
520 || (!shouldNotAnswer && content.Split(' ').First().Equals(ChatManager.CommonMention, StringComparison.OrdinalIgnoreCase));
521
522 if (shouldNotAnswer)
523 {
524 if (mentionedUs)
525 Logger.LogTrace(
526 "Ignoring mention from {channelId} ({channelName}) by {authorId} ({authorName}). Channel not mapped!",
527 messageCreateEvent.ChannelID,
528 channelResponse.Entity.Name,
529 messageCreateEvent.Author.ID,
530 messageCreateEvent.Author.Username);
531
532 return Result.FromSuccess();
533 }
534
535 string guildName = "UNKNOWN";
536 if (!pm)
537 {
538 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
539 var messageGuildResponse = await guildsClient.GetGuildAsync(messageCreateEvent.GuildID.Value, false, cancellationToken);
540 if (messageGuildResponse.IsSuccess)
541 guildName = messageGuildResponse.Entity.Name;
542 else
543 Logger.LogWarning(
544 "Failed to get channel {channelID} in response to message {messageID}: {result}",
545 messageCreateEvent.ChannelID,
546 messageCreateEvent.ID,
547 messageGuildResponse.LogFormat());
548 }
549
550 var result = new DiscordMessage(
551 new ChatUser(
553 pm ? messageCreateEvent.Author.Username : guildName,
554 channelResponse.Entity.Name.Value!,
555 messageCreateEvent.ChannelID.Value)
556 {
557 IsPrivateChannel = pm,
558 EmbedsSupported = true,
559
560 // isAdmin and Tag populated by manager
561 },
562 messageCreateEvent.Author.Username,
563 NormalizeMentions($"<@{messageCreateEvent.Author.ID}>"),
564 messageCreateEvent.Author.ID.Value),
565 content,
566 messageReference);
567
568 EnqueueMessage(result);
569 return Result.FromSuccess();
570 }
571
573 public Task<Result> RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
574 {
575 ArgumentNullException.ThrowIfNull(readyEvent);
576
577 Logger.LogTrace("Gatway ready. Version: {version}", readyEvent.Version);
578 gatewayReadyTcs?.TrySetResult();
579 return Task.FromResult(Result.FromSuccess());
580 }
581
583 protected override async ValueTask Connect(CancellationToken cancellationToken)
584 {
585 try
586 {
588 {
589 if (gatewayCts != null)
590 throw new InvalidOperationException("Discord gateway still active!");
591
592 gatewayCts = new CancellationTokenSource();
593 }
594
595 var gatewayCancellationToken = gatewayCts.Token;
596 var gatewayClient = serviceProvider.GetRequiredService<DiscordGatewayClient>();
597
598 Task<Result> localGatewayTask;
599 gatewayReadyTcs = new TaskCompletionSource();
600
601 using var gatewayConnectionAbortRegistration = cancellationToken.Register(() => gatewayReadyTcs.TrySetCanceled(cancellationToken));
602 gatewayCancellationToken.Register(() => Logger.LogTrace("Stopping gateway client..."));
603
604 // reconnects keep happening until we stop or it faults, our auto-reconnector will handle the latter
605 localGatewayTask = gatewayClient.RunAsync(gatewayCancellationToken);
606 try
607 {
608 await Task.WhenAny(gatewayReadyTcs.Task, localGatewayTask);
609
610 cancellationToken.ThrowIfCancellationRequested();
611 if (localGatewayTask.IsCompleted)
612 throw new JobException(ErrorCode.ChatCannotConnectProvider);
613
614 var userClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
615
616 using var localCombinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, gatewayCancellationToken);
617 var localCombinedCancellationToken = localCombinedCts.Token;
618 var currentUserResult = await userClient.GetCurrentUserAsync(localCombinedCancellationToken);
619 if (!currentUserResult.IsSuccess)
620 {
621 localCombinedCancellationToken.ThrowIfCancellationRequested();
622 Logger.LogWarning("Unable to retrieve current user: {result}", currentUserResult.LogFormat());
623 throw new JobException(ErrorCode.ChatCannotConnectProvider);
624 }
625
626 currentUserId = currentUserResult.Entity.ID;
627 }
628 finally
629 {
630 gatewayTask = localGatewayTask;
631 }
632 }
633 catch
634 {
635 // will handle cleanup
636 // DCT: Musn't abort
637 await DisconnectImpl(CancellationToken.None);
638 throw;
639 }
640 }
641
643 protected override async ValueTask DisconnectImpl(CancellationToken cancellationToken)
644 {
645 Task<Result> localGatewayTask;
646 CancellationTokenSource localGatewayCts;
648 {
649 localGatewayTask = gatewayTask!;
650 localGatewayCts = gatewayCts!;
651 gatewayTask = null;
652 gatewayCts = null;
653 if (localGatewayTask == null)
654 return;
655 }
656
657 localGatewayCts.Cancel();
658 var gatewayResult = await localGatewayTask;
659
660 Logger.LogTrace("Gateway task complete");
661 if (!gatewayResult.IsSuccess)
662 Logger.LogWarning("Gateway issue: {result}", gatewayResult.LogFormat());
663
664 localGatewayCts.Dispose();
665 }
666
668 protected override async ValueTask<Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> MapChannelsImpl(IEnumerable<Models.ChatChannel> channels, CancellationToken cancellationToken)
669 {
670 ArgumentNullException.ThrowIfNull(channels);
671
672 var remapRequired = false;
673 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
674 var guildTasks = new ConcurrentDictionary<Snowflake, Task<Result<IGuild>>>();
675
676 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>?> GetModelChannelFromDBChannel(Models.ChatChannel channelFromDB)
677 {
678 if (!channelFromDB.DiscordChannelId.HasValue)
679 throw new InvalidOperationException("ChatChannel missing DiscordChannelId!");
680
681 var channelId = channelFromDB.DiscordChannelId.Value;
682 var channelsClient = serviceProvider.GetRequiredService<IDiscordRestChannelAPI>();
683 var discordChannelResponse = await channelsClient.GetChannelAsync(new Snowflake(channelId), cancellationToken);
684 if (!discordChannelResponse.IsSuccess)
685 {
686 Logger.LogWarning(
687 "Error retrieving discord channel {channelId}: {result}",
688 channelId,
689 discordChannelResponse.LogFormat());
690
691 var remapConditional = !(discordChannelResponse.Error is RestResultError<RestError> restResultError
692 && (restResultError.Error?.Code == DiscordError.MissingAccess
693 || restResultError.Error?.Code == DiscordError.UnknownChannel));
694
695 if (remapConditional)
696 {
697 Logger.Log(
698 remapRequired
699 ? LogLevel.Trace
700 : LogLevel.Debug,
701 "Error on channel {channelId} is not an access/thread issue. Will retry remap...",
702 channelId);
703 remapRequired = true;
704 }
705
706 return null;
707 }
708
709 var channelType = discordChannelResponse.Entity.Type;
710 if (!SupportedGuildChannelTypes.Contains(channelType))
711 {
712 Logger.LogWarning("Cound not map channel {channelId}! Incorrect type: {channelType}", channelId, discordChannelResponse.Entity.Type);
713 return null;
714 }
715
716 var guildId = discordChannelResponse.Entity.GuildID.Value;
717
718 var added = false;
719 var guildsResponse = await guildTasks.GetOrAdd(
720 guildId,
721 localGuildId =>
722 {
723 added = true;
724 return guildsClient.GetGuildAsync(
725 localGuildId,
726 false,
727 cancellationToken);
728 });
729 if (!guildsResponse.IsSuccess)
730 {
731 if (added)
732 {
733 Logger.LogWarning(
734 "Error retrieving discord guild {guildID}: {result}",
735 guildId,
736 guildsResponse.LogFormat());
737 remapRequired = true;
738 }
739
740 return null;
741 }
742
743 var connectionName = guildsResponse.Entity.Name;
744
745 var channelModel = new ChannelRepresentation(
746 guildsResponse.Entity.Name,
747 discordChannelResponse.Entity.Name.Value!,
748 channelId)
749 {
750 IsAdminChannel = channelFromDB.IsAdminChannel == true,
751 IsPrivateChannel = false,
752 Tag = channelFromDB.Tag,
753 EmbedsSupported = true,
754 };
755
756 Logger.LogTrace("Mapped channel {realId}: {friendlyName}", channelModel.RealId, channelModel.FriendlyName);
757 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
758 channelFromDB,
759 new List<ChannelRepresentation> { channelModel });
760 }
761
762 var tasks = channels
763 .Where(x => x.DiscordChannelId != 0)
764 .Select(GetModelChannelFromDBChannel);
765
766 var channelTuples = await ValueTaskExtensions.WhenAll(tasks.ToList());
767
768 var list = channelTuples
769 .Where(x => x != null)
770 .Cast<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>>() // NRT my beloathed
771 .ToList();
772
773 var channelIdZeroModel = channels.FirstOrDefault(x => x.DiscordChannelId == 0);
774 if (channelIdZeroModel != null)
775 {
776 Logger.LogInformation("Mapping ALL additional accessible text channels");
777 var allAccessibleChannels = await GetAllAccessibleTextChannels(cancellationToken);
778 var unmappedTextChannels = allAccessibleChannels
779 .Where(x => !tasks.Any(task => task.Result != null && new Snowflake(task.Result.Item1.DiscordChannelId!.Value) == x.ID));
780
781 async ValueTask<Tuple<Models.ChatChannel, IEnumerable<ChannelRepresentation>>> CreateMappingsForUnmappedChannels()
782 {
783 var unmappedTasks =
784 unmappedTextChannels.Select(
785 async unmappedTextChannel =>
786 {
787 var fakeChannelModel = new Models.ChatChannel
788 {
789 DiscordChannelId = unmappedTextChannel.ID.Value,
790 IsAdminChannel = channelIdZeroModel.IsAdminChannel,
791 Tag = channelIdZeroModel.Tag,
792 };
793
794 var tuple = await GetModelChannelFromDBChannel(fakeChannelModel);
795 return tuple?.Item2.First();
796 })
797 .ToList();
798
799 // Add catch-all channel
800 unmappedTasks.Add(Task.FromResult<ChannelRepresentation?>(
802 "(Unknown Discord Guilds)",
803 "(Unknown Discord Channels)",
804 0)
805 {
806 IsAdminChannel = channelIdZeroModel.IsAdminChannel!.Value,
807 EmbedsSupported = true,
808 Tag = channelIdZeroModel.Tag,
809 }));
810
811 await Task.WhenAll(unmappedTasks);
812 return Tuple.Create<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(
813 channelIdZeroModel,
814 unmappedTasks
815 .Select(x => x.Result)
816 .Where(x => x != null)
817 .Cast<ChannelRepresentation>() // NRT my beloathed
818 .ToList());
819 }
820
821 var task = CreateMappingsForUnmappedChannels();
822 var tuple = await task;
823 list.Add(tuple);
824 }
825
826 lock (mappedChannels)
827 {
828 mappedChannels.Clear();
829 mappedChannels.AddRange(list.SelectMany(x => x.Item2).Select(x => x.RealId));
830 }
831
832 if (remapRequired)
833 {
834 Logger.LogWarning("Some channels failed to load with unknown errors. We will request that these be remapped, but it may result in communication spam. Please check prior logs and report an issue if this occurs.");
835 EnqueueMessage(null);
836 }
837
838 return new Dictionary<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(list.Select(x => new KeyValuePair<Models.ChatChannel, IEnumerable<ChannelRepresentation>>(x.Item1, x.Item2)));
839 }
840
846 async ValueTask<IEnumerable<IChannel>> GetAllAccessibleTextChannels(CancellationToken cancellationToken)
847 {
848 var usersClient = serviceProvider.GetRequiredService<IDiscordRestUserAPI>();
849 var currentGuildsResponse = await usersClient.GetCurrentUserGuildsAsync(ct: cancellationToken);
850 if (!currentGuildsResponse.IsSuccess)
851 {
852 Logger.LogWarning(
853 "Error retrieving current discord guilds: {result}",
854 currentGuildsResponse.LogFormat());
855 return Enumerable.Empty<IChannel>();
856 }
857
858 var guildsClient = serviceProvider.GetRequiredService<IDiscordRestGuildAPI>();
859
860 async ValueTask<IEnumerable<IChannel>> GetGuildChannels(IPartialGuild guild)
861 {
862 var channelsTask = guildsClient.GetGuildChannelsAsync(guild.ID.Value, cancellationToken);
863 var threads = await guildsClient.ListActiveGuildThreadsAsync(guild.ID.Value, cancellationToken);
864 if (!threads.IsSuccess)
865 Logger.LogWarning(
866 "Error retrieving discord guild threads {guildId} ({guildName}): {result}",
867 guild.ID,
868 guild.Name,
869 threads.LogFormat());
870
871 var channels = await channelsTask;
872 if (!channels.IsSuccess)
873 Logger.LogWarning(
874 "Error retrieving discord guild channels {guildId} ({guildName}): {result}",
875 guild.ID,
876 guild.Name,
877 channels.LogFormat());
878
879 if (!channels.IsSuccess && !threads.IsSuccess)
880 return Enumerable.Empty<IChannel>();
881
882 if (channels.IsSuccess && threads.IsSuccess)
883 return channels.Entity.Concat(threads.Entity.Threads ?? Enumerable.Empty<IChannel>());
884
885 return channels.Entity ?? threads.Entity?.Threads ?? Enumerable.Empty<IChannel>();
886 }
887
888 var guildsChannelsTasks = currentGuildsResponse.Entity
889 .Select(GetGuildChannels);
890
891 var guildsChannels = await ValueTaskExtensions.WhenAll(guildsChannelsTasks, currentGuildsResponse.Entity.Count);
892
893 var allAccessibleChannels = guildsChannels
894 .SelectMany(channels => channels)
895 .Where(guildChannel => SupportedGuildChannelTypes.Contains(guildChannel.Type));
896
897 return allAccessibleChannels;
898 }
899
910 List<IEmbedField> BuildUpdateEmbedFields(
911 Models.RevisionInformation revisionInformation,
912 Models.RevisionInformation? previousRevisionInformation,
913 EngineVersion engineVersion,
914 string? gitHubOwner,
915 string? gitHubRepo,
916 bool localCommitPushed)
917 {
918 bool gitHub = gitHubOwner != null && gitHubRepo != null;
919 var engineField = engineVersion.Engine!.Value switch
920 {
921 EngineType.Byond => new EmbedField(
922 "BYOND Version",
923 $"{engineVersion.Version!.Major}.{engineVersion.Version.Minor}{(engineVersion.CustomIteration.HasValue ? $".{engineVersion.CustomIteration.Value}" : String.Empty)}",
924 true),
925 EngineType.OpenDream => new EmbedField(
926 "OpenDream Version",
927 $"[{engineVersion.SourceSHA![..7]}]({generalConfiguration.OpenDreamGitUrl}/commit/{engineVersion.SourceSHA})",
928 true),
929 _ => throw new InvalidOperationException($"Invaild EngineType: {engineVersion.Engine.Value}"),
930 };
931
932 var revisionSha = revisionInformation.CommitSha!;
933 var revisionOriginSha = revisionInformation.OriginCommitSha!;
934 var fields = new List<IEmbedField>
935 {
936 engineField,
937 };
938
939 if (gitHubOwner == null || gitHubRepo == null)
940 return fields;
941
942 var previousTestMerges = (IEnumerable<RevInfoTestMerge>?)previousRevisionInformation?.ActiveTestMerges ?? Enumerable.Empty<RevInfoTestMerge>();
943 var currentTestMerges = (IEnumerable<RevInfoTestMerge>?)revisionInformation.ActiveTestMerges ?? Enumerable.Empty<RevInfoTestMerge>();
944
945 // determine what TMs were changed and how
946 var addedTestMerges = currentTestMerges
947 .Select(x => x.TestMerge)
948 .Where(x => !previousTestMerges
949 .Any(y => y.TestMerge.Number == x.Number))
950 .ToList();
951 var removedTestMerges = previousTestMerges
952 .Select(x => x.TestMerge)
953 .Where(x => !currentTestMerges
954 .Any(y => y.TestMerge.Number == x.Number))
955 .ToList();
956 var updatedTestMerges = currentTestMerges
957 .Select(x => x.TestMerge)
958 .Where(x => previousTestMerges
959 .Any(y => y.TestMerge.Number == x.Number && y.TestMerge.TargetCommitSha != x.TargetCommitSha))
960 .ToList();
961 var unchangedTestMerges = currentTestMerges
962 .Select(x => x.TestMerge)
963 .Where(x => previousTestMerges
964 .Any(y => y.TestMerge.Number == x.Number && y.TestMerge.TargetCommitSha == x.TargetCommitSha))
965 .ToList();
966
967 fields.Add(
968 new EmbedField(
969 "Local Commit",
970 localCommitPushed && gitHub
971 ? $"[{revisionSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionSha})"
972 : revisionSha[..7],
973 true));
974
975 fields.Add(
976 new EmbedField(
977 "Branch Commit",
978 gitHub
979 ? $"[{revisionOriginSha[..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{revisionOriginSha})"
980 : revisionOriginSha[..7],
981 true));
982
983 fields.AddRange(addedTestMerges
984 .Select(x => new EmbedField(
985 $"#{x.Number} (Added)",
986 $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.TargetCommitSha![..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.TargetCommitSha}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}",
987 false)));
988
989 fields.AddRange(updatedTestMerges
990 .Select(x => new EmbedField(
991 $"#{x.Number} (Updated)",
992 $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.TargetCommitSha![..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.TargetCommitSha}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}",
993 false)));
994
995 fields.AddRange(unchangedTestMerges
996 .Select(x => new EmbedField(
997 $"#{x.Number}",
998 $"[{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_{Environment.NewLine}Commit: [{x.TargetCommitSha![..7]}](https://github.com/{gitHubOwner}/{gitHubRepo}/commit/{x.TargetCommitSha}){(String.IsNullOrWhiteSpace(x.Comment) ? String.Empty : $"{Environment.NewLine}_**{x.Comment}**_")}",
999 false)));
1000
1001 if (removedTestMerges.Count != 0)
1002 fields.Add(
1003 new EmbedField(
1004 "Removed:",
1005 String.Join(
1006 Environment.NewLine,
1007 removedTestMerges
1008 .Select(x => $"- #{x.Number} [{x.TitleAtMerge}]({x.Url}) by _[@{x.Author}](https://github.com/{x.Author})_"))));
1009
1010 return fields;
1011 }
1012
1018#pragma warning disable CA1502
1019 Optional<IReadOnlyList<IEmbed>> ConvertEmbed(ChatEmbed? embed)
1020 {
1021 if (embed == null)
1022 return default;
1023
1024 var embedErrors = new List<string>();
1025 Optional<Color> colour = default;
1026 if (embed.Colour != null)
1027 if (Int32.TryParse(embed.Colour[1..], NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var argb))
1028 colour = Color.FromArgb(argb);
1029 else
1030 embedErrors.Add(
1031 String.Format(
1032 CultureInfo.InvariantCulture,
1033 "Invalid embed colour: {0}",
1034 embed.Colour));
1035
1036 if (embed.Author != null && String.IsNullOrWhiteSpace(embed.Author.Name))
1037 {
1038 embedErrors.Add("Null or whitespace embed author name!");
1039 embed.Author = null;
1040 }
1041
1042 List<IEmbedField>? fields = null;
1043 if (embed.Fields != null)
1044 {
1045 fields = new List<IEmbedField>();
1046 var i = -1;
1047 foreach (var field in embed.Fields)
1048 {
1049 ++i;
1050 var invalid = false;
1051 if (String.IsNullOrWhiteSpace(field.Name))
1052 {
1053 embedErrors.Add(
1054 String.Format(
1055 CultureInfo.InvariantCulture,
1056 "Null or whitespace field name at index {0}!",
1057 i));
1058 invalid = true;
1059 }
1060
1061 if (String.IsNullOrWhiteSpace(field.Value))
1062 {
1063 embedErrors.Add(
1064 String.Format(
1065 CultureInfo.InvariantCulture,
1066 "Null or whitespace field value at index {0}!",
1067 i));
1068 invalid = true;
1069 }
1070
1071 if (invalid)
1072 continue;
1073
1074 fields.Add(new EmbedField(field.Name!, field.Value!)
1075 {
1076 IsInline = field.IsInline ?? default(Optional<bool>),
1077 });
1078 }
1079 }
1080
1081 if (embed.Footer != null && String.IsNullOrWhiteSpace(embed.Footer.Text))
1082 {
1083 embedErrors.Add("Null or whitespace embed footer text!");
1084 embed.Footer = null;
1085 }
1086
1087 if (embed.Image != null && String.IsNullOrWhiteSpace(embed.Image.Url))
1088 {
1089 embedErrors.Add("Null or whitespace embed image url!");
1090 embed.Image = null;
1091 }
1092
1093 if (embed.Thumbnail != null && String.IsNullOrWhiteSpace(embed.Thumbnail.Url))
1094 {
1095 embedErrors.Add("Null or whitespace embed thumbnail url!");
1096 embed.Thumbnail = null;
1097 }
1098
1099 Optional<DateTimeOffset> timestampOptional = default;
1100 if (embed.Timestamp != null)
1101 if (DateTimeOffset.TryParse(embed.Timestamp, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var timestamp))
1102 timestampOptional = timestamp.ToUniversalTime();
1103 else
1104 embedErrors.Add(
1105 String.Format(
1106 CultureInfo.InvariantCulture,
1107 "Invalid embed timestamp: {0}",
1108 embed.Timestamp));
1109
1110 var discordEmbed = new Embed
1111 {
1112 Author = embed.Author != null
1113 ? new EmbedAuthor(embed.Author.Name!)
1114 {
1115 IconUrl = embed.Author.IconUrl ?? default(Optional<string>),
1116 ProxyIconUrl = embed.Author.ProxyIconUrl ?? default(Optional<string>),
1117 Url = embed.Author.Url ?? default(Optional<string>),
1118 }
1119 : default(Optional<IEmbedAuthor>),
1120 Colour = colour,
1121 Description = embed.Description ?? default(Optional<string>),
1122 Fields = fields ?? default(Optional<IReadOnlyList<IEmbedField>>),
1123 Footer = embed.Footer != null
1124 ? (Optional<IEmbedFooter>)new EmbedFooter(embed.Footer.Text!)
1125 {
1126 IconUrl = embed.Footer.IconUrl ?? default(Optional<string>),
1127 ProxyIconUrl = embed.Footer.ProxyIconUrl ?? default(Optional<string>),
1128 }
1129 : default,
1130 Image = embed.Image != null
1131 ? new EmbedImage(embed.Image.Url!)
1132 {
1133 Width = embed.Image.Width ?? default(Optional<int>),
1134 Height = embed.Image.Height ?? default(Optional<int>),
1135 ProxyUrl = embed.Image.ProxyUrl ?? default(Optional<string>),
1136 }
1137 : default(Optional<IEmbedImage>),
1138 Provider = embed.Provider != null
1139 ? new EmbedProvider
1140 {
1141 Name = embed.Provider.Name ?? default(Optional<string>),
1142 Url = embed.Provider.Url ?? default(Optional<string>),
1143 }
1144 : default(Optional<IEmbedProvider>),
1145 Thumbnail = embed.Thumbnail != null
1146 ? new EmbedThumbnail(embed.Thumbnail.Url!)
1147 {
1148 Width = embed.Thumbnail.Width ?? default(Optional<int>),
1149 Height = embed.Thumbnail.Height ?? default(Optional<int>),
1150 ProxyUrl = embed.Thumbnail.ProxyUrl ?? default(Optional<string>),
1151 }
1152 : default(Optional<IEmbedThumbnail>),
1153 Timestamp = timestampOptional,
1154 Title = embed.Title ?? default(Optional<string>),
1155 Url = embed.Url ?? default(Optional<string>),
1156 Video = embed.Video != null
1157 ? new EmbedVideo
1158 {
1159 Url = embed.Video.Url ?? default(Optional<string>),
1160 Width = embed.Video.Width ?? default(Optional<int>),
1161 Height = embed.Video.Height ?? default(Optional<int>),
1162 ProxyUrl = embed.Video.ProxyUrl ?? default(Optional<string>),
1163 }
1164 : default(Optional<IEmbedVideo>),
1165 };
1166
1167 var result = new List<IEmbed> { discordEmbed };
1168
1169 if (embedErrors.Count > 0)
1170 {
1171 var joinedErrors = String.Join(Environment.NewLine, embedErrors);
1172 Logger.LogError("Embed description contains errors:{newLine}{issues}", Environment.NewLine, joinedErrors);
1173 result.Add(new Embed
1174 {
1175 Title = "TGS Embed Errors",
1176 Description = joinedErrors,
1177 Colour = Color.Red,
1178 Footer = new EmbedFooter("Please report this to your codebase's maintainers."),
1179 Timestamp = DateTimeOffset.UtcNow,
1180 });
1181 }
1182
1183 return result;
1184 }
1185 #pragma warning restore CA1502
1186 }
1187#pragma warning restore CA1506
1188}
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, ChatBot chatBot, GeneralConfiguration generalConfiguration)
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 GeneralConfiguration generalConfiguration
The GeneralConfiguration for the DiscordProvider.
Task< Result > RespondAsync(IReady readyEvent, CancellationToken cancellationToken)
readonly ServiceProvider serviceProvider
The ServiceProvider containing Discord services.
CancellationTokenSource? gatewayCts
The CancellationTokenSource for the gatewayTask.
override string BotMention
The string that indicates the IProvider was mentioned.
Represents a message received by a IProvider.
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.