diff --git a/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs b/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs index 35d5c29..4bd2896 100644 --- a/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs +++ b/src/GmRelay.DiscordBot/Infrastructure/Discord/DiscordPlatformMessenger.cs @@ -272,14 +272,16 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger ? "—" : string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User))); - return new EmbedProperties() + var embed = new EmbedProperties() .WithTitle($"Ссылка на игру: {notification.Title}") .WithDescription( $"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" + $"Ссылка: {notification.JoinLink}\n\n" + $"Участники: {mentions}") - .WithUrl(notification.JoinLink) .WithColor(new Color(0x57F287)); + + var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(notification.JoinLink); + return embedUrl is null ? embed : embed.WithUrl(embedUrl); } private static IReadOnlyList BuildRsvpRows(Guid sessionId, bool disabled) diff --git a/src/GmRelay.DiscordBot/Rendering/DiscordEmbedUrls.cs b/src/GmRelay.DiscordBot/Rendering/DiscordEmbedUrls.cs new file mode 100644 index 0000000..cddbe3a --- /dev/null +++ b/src/GmRelay.DiscordBot/Rendering/DiscordEmbedUrls.cs @@ -0,0 +1,43 @@ +namespace GmRelay.DiscordBot.Rendering; + +public static class DiscordEmbedUrls +{ + public static string? NormalizeHttpUrl(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var candidate = value.Trim(); + if (IsSupportedHttpUrl(candidate, out var normalized)) + return normalized; + + if (candidate.Contains("://", StringComparison.Ordinal)) + return null; + + return IsSupportedHttpUrl($"https://{candidate}", out normalized) + && HasPublicHost(normalized) + ? normalized + : null; + } + + private static bool IsSupportedHttpUrl(string value, out string normalized) + { + normalized = string.Empty; + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + return false; + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + normalized = uri.ToString(); + return true; + } + + private static bool HasPublicHost(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) + && uri.Host.Contains('.', StringComparison.Ordinal); +} diff --git a/src/GmRelay.DiscordBot/Rendering/DiscordSessionBatchRenderer.cs b/src/GmRelay.DiscordBot/Rendering/DiscordSessionBatchRenderer.cs index 81aecf0..6063436 100644 --- a/src/GmRelay.DiscordBot/Rendering/DiscordSessionBatchRenderer.cs +++ b/src/GmRelay.DiscordBot/Rendering/DiscordSessionBatchRenderer.cs @@ -70,9 +70,10 @@ public static class DiscordSessionBatchRenderer .WithInline() }; - if (!string.IsNullOrEmpty(session.JoinLink)) + var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(session.JoinLink); + if (embedUrl is not null) { - embed = embed.WithUrl(session.JoinLink); + embed = embed.WithUrl(embedUrl); } embed = embed.WithColor(GetColor(session)); diff --git a/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs b/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs index 86328a5..9dfc6df 100644 --- a/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs +++ b/tests/GmRelay.Bot.Tests/Rendering/DiscordSessionBatchRendererTests.cs @@ -176,6 +176,32 @@ public sealed class DiscordSessionBatchRendererTests Assert.Equal("https://example.com/game", embeds[0].Url); } + [Fact] + public void Render_ShouldNormalizeBareDomainJoinLinkForEmbedUrl() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "mobaxterm.mobatek.net/game") }; + var participants = Array.Empty(); + + var view = SessionBatchViewBuilder.Build("Test", sessions, participants); + var (embeds, _) = DiscordSessionBatchRenderer.Render(view); + + Assert.Equal("https://mobaxterm.mobatek.net/game", embeds[0].Url); + } + + [Fact] + public void Render_ShouldNotSetEmbedUrlWhenJoinLinkIsNotHttpUrl() + { + var sessionId = Guid.NewGuid(); + var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "test") }; + var participants = Array.Empty(); + + var view = SessionBatchViewBuilder.Build("Test", sessions, participants); + var (embeds, _) = DiscordSessionBatchRenderer.Render(view); + + Assert.Null(embeds[0].Url); + } + [Fact] public void Render_ShouldEmbedCorrectFieldValues() {