diff --git a/samples/SocketModeBot/README.md b/samples/SocketModeBot/README.md index 2f024ae8..02276a39 100644 --- a/samples/SocketModeBot/README.md +++ b/samples/SocketModeBot/README.md @@ -9,8 +9,8 @@ This sample is an echo bot that receives activities over Socket Mode instead of ## What it shows -- `UseSocketMode()` on `AddTeamsBotApplication` to receive activities over Socket Mode using the default production endpoint (`https://botapi.skype.com`). -- One connection per geo (`amer`, `emea`, `apac`) by default for inbound coverage across regions. Override with `Geos = [...]` or point at a custom ring with `NegotiateBaseUrl` in the `UseSocketMode(socket => ...)` callback. +- `UseSocketMode()` on `AddTeamsBotApplication` to receive activities over Socket Mode. +- One connection per geo (`amer`, `emea`, `apac`) by default for inbound coverage across regions. Configure `Geos = [...]` in the `UseSocketMode(socket => ...)` callback to target specific geos from these three defaults. - `$(NoWarn);ExperimentalTeamsSocketMode` in the project file, because Socket Mode is experimental. - A generic host (`Host.CreateApplicationBuilder`) with no web server, and `UseTeamsBotApplication()` to get the app. - Per-geo `Ready`, `Disconnected`, and `Reconnected` subscriptions in `Program.cs`, plus a status snapshot after startup. diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index 2f6c29a6..48a83e7e 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -245,7 +245,11 @@ Negotiation failures expose `SocketModeNegotiateException.StatusCode`, `.RetryAf and `.IsAuthError` for HTTP 401/403. Downstream SignalR negotiation errors are not classified as terminal service-negotiation rejections. -`UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `NegotiateBaseUrl` and `Geos`. +`UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `Geos`. +Use this setting to target specific geos from the three defaults: `amer`, `emea`, and `apac`. +An empty geo list uses all three defaults. +Each geo's resolved connect/negotiate URL is logged at Information level during startup +under `Microsoft.Teams.Apps.SocketMode.SocketModeTransport`. The SignalR URL returned by negotiation is used unchanged. Startup and readiness timeouts and reconnect backoff are managed internally by the SDK. Keep-alive and server timeout use the SignalR client defaults of 15 and 30 seconds, respectively. `UseSocketMode(bool)` turns Socket Mode on with the defaults, or off with `false`, which clears any earlier diff --git a/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs b/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs index ce211c99..7ff8be33 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs @@ -116,7 +116,7 @@ internal sealed class GeoSocket : IAsyncDisposable /// Initializes a supervisor for one geo. /// /// The transport that owns this geo. - /// The geo identifier. Empty when the negotiate URL has no geo segment. + /// The geo identifier. /// The negotiate endpoint for the geo. /// Creates one connection per generation. /// The logger for lifecycle events. diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeObserver.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeObserver.cs index c2a5499c..1e664210 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeObserver.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeObserver.cs @@ -40,5 +40,6 @@ internal SocketModeObserver(Func getTransport, SocketModeEv /// Gets the resolved negotiation endpoint for the first configured geo, for diagnostics. /// Each geo connects to its own endpoint. /// + /// This URL is managed by the SDK and is for diagnostics only. Do not depend on it; it may change in the future. public Uri NegotiateUrl => _transport.Value.NegotiateUrl; } diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs index afdac15b..0b69e41e 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs @@ -13,6 +13,7 @@ namespace Microsoft.Teams.Apps.SocketMode; /// Socket Mode opens one connection per geo and waits until every geo is ready before the host finishes starting. /// It is supported only in the public cloud, and runs on a host without a web server /// (Host.CreateApplicationBuilder). Settings are validated when the host starts. +/// Negotiation URLs are managed by the SDK. Do not depend on these URLs; they may change in the future. /// Socket Mode is experimental: the API is in preview and may change, and it is recommended only for developing agents. /// Socket Mode is currently only available for new bots. /// @@ -20,14 +21,9 @@ namespace Microsoft.Teams.Apps.SocketMode; public sealed class SocketModeOptions { /// - /// Gets or sets the base URL used to negotiate each geo connection. Must use HTTPS unless it targets loopback. - /// Defaults to https://botapi.skype.com. Override this to target a different Teams backend service ring. - /// - public Uri NegotiateBaseUrl { get; set; } = new(SocketModeProtocol.DefaultNegotiateBaseUrl); - - /// - /// Gets or sets the geos to connect, one connection each. An empty string connects to the base URL without a geo - /// segment. Defaults to amer, emea, and apac. Override this to connect to a different set of geos. + /// Gets or sets the geos to connect, one connection each. + /// Defaults to amer, emea, and apac, including when the list is empty. + /// Override this to connect to a different set of geos. /// public IReadOnlyList Geos { get; set; } = SocketModeProtocol.DefaultGeos; @@ -38,7 +34,6 @@ public sealed class SocketModeOptions /// The transport options. internal SocketModeTransportOptions ToTransportOptions() => new() { - NegotiateBaseUri = NegotiateBaseUrl, Geos = Geos is null ? null! : [.. Geos], }; } diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs index 675df4ba..92b25c99 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs @@ -14,9 +14,9 @@ internal static class SocketModeProtocol internal const int CurrentVersion = 1; /// - /// The default base URL for Socket Mode negotiation. + /// The service hostname used for Socket Mode negotiation. /// - internal const string DefaultNegotiateBaseUrl = "https://botapi.skype.com"; + internal const string NegotiateHost = "botapi.skype.com"; /// /// The default geographies for Socket Mode connections. @@ -25,7 +25,7 @@ internal static class SocketModeProtocol Array.AsReadOnly(["amer", "emea", "apac"]); /// - /// The negotiate path appended after the geography segment. + /// The negotiate path appended to each geo's base URL. /// internal const string NegotiatePath = "/v3/websockets/connect"; diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs index da0f72d4..bcf0b5d9 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs @@ -48,12 +48,7 @@ public enum SocketModeStatus internal sealed class SocketModeTransportOptions { /// - /// Gets the base URL used to negotiate each geo connection. - /// - internal Uri NegotiateBaseUri { get; init; } = new(SocketModeProtocol.DefaultNegotiateBaseUrl); - - /// - /// Gets the geos to connect. An empty string connects to the base URL without a geo segment. + /// Gets the geos to connect. An empty list uses the default geos. /// internal IReadOnlyList Geos { get; init; } = SocketModeProtocol.DefaultGeos; @@ -117,7 +112,7 @@ internal sealed class SocketModeTransport : IGeoSocketOwner, IAsyncDisposable /// The time provider for supervision timing and reply timestamps. /// The random source for reconnect jitter. /// The optional public lifecycle observers. - /// Thrown when the geos are empty, null, or duplicated. + /// Thrown when the geo list is null or contains null, empty, or duplicate entries. /// Thrown when the startup timeout is negative. internal SocketModeTransport( SocketModeTransportOptions options, @@ -140,9 +135,8 @@ internal SocketModeTransport( _random = random ?? Random.Shared; _events = events; - ArgumentNullException.ThrowIfNull(options.NegotiateBaseUri, nameof(options)); ArgumentOutOfRangeException.ThrowIfLessThan(options.StartupTimeout, TimeSpan.Zero, nameof(options)); - _geos = ResolveGeos(options.NegotiateBaseUri, options.Geos); + _geos = ResolveGeos(options.Geos); } /// @@ -241,6 +235,14 @@ internal async Task StartAsync(CancellationToken cancellationToken = default) geoSockets.Length, string.Join(", ", geoSockets.Select(geoSocket => geoSocket.Geo))); + foreach (var geo in _geos) + { + _logger.LogInformation( + "Socket Mode geo {Geo} negotiate URL {NegotiateUrl}.", + geo.Geo, + geo.NegotiateUri); + } + using CancellationTokenSource startSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Exception? firstFailure = null; @@ -494,17 +496,28 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ TeamsActivityTypes.Invoke, StringComparison.OrdinalIgnoreCase); - private static List<(string Geo, Uri NegotiateUri)> ResolveGeos(Uri baseUri, IReadOnlyList geos) + private static List<(string Geo, Uri NegotiateUri)> ResolveGeos(IReadOnlyList geos) { - if (geos is null || geos.Count == 0) + if (geos is null) + { + throw new ArgumentException("Socket Mode geos cannot be null.", nameof(geos)); + } + + if (geos.Count == 0) { - throw new ArgumentException( - "Socket Mode requires at least one geo. Use an empty string to connect without a geo segment.", - nameof(geos)); + geos = SocketModeProtocol.DefaultGeos; + } + + if (geos.Contains(string.Empty)) + { + throw new ArgumentException("Socket Mode geos must not contain an empty string.", nameof(geos)); + } + + if (new HashSet(geos, StringComparer.Ordinal).Count != geos.Count) + { + throw new ArgumentException("Socket Mode geos must not contain duplicates.", nameof(geos)); } - string baseUrl = baseUri.AbsoluteUri.TrimEnd('/'); - HashSet seen = new(StringComparer.OrdinalIgnoreCase); List<(string Geo, Uri NegotiateUri)> resolved = []; foreach (string geo in geos) @@ -514,16 +527,11 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ throw new ArgumentException("Socket Mode geos cannot contain null.", nameof(geos)); } - string segment = geo.Trim().Trim('/'); - if (!seen.Add(segment)) + UriBuilder builder = new(Uri.UriSchemeHttps, $"{Uri.EscapeDataString(geo)}.{SocketModeProtocol.NegotiateHost}") { - throw new ArgumentException($"Socket Mode geo '{geo}' is listed more than once.", nameof(geos)); - } - - string path = segment.Length > 0 - ? $"/{Uri.EscapeDataString(segment)}{SocketModeProtocol.NegotiatePath}" - : SocketModeProtocol.NegotiatePath; - resolved.Add((segment, new Uri(baseUrl + path, UriKind.Absolute))); + Path = SocketModeProtocol.NegotiatePath, + }; + resolved.Add((geo, builder.Uri)); } return resolved; diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs index 8ac4ff59..31229dbb 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs @@ -10,7 +10,7 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode; public class GeoSocketTests { - private static readonly Uri NegotiateUri = new("https://botapi.skype.com/amer/v3/websockets/connect"); + private static readonly Uri NegotiateUri = new("https://amer.botapi.skype.com/v3/websockets/connect"); [Fact] public async Task StartAsync_CompletesOnlyAfterSocketReady() diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs index 44703549..1ab7eec1 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs @@ -9,9 +9,9 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode; public class SignalRSocketConnectionTests { private static readonly Uri NegotiateUri = - new("https://botapi.skype.com/amer/v3/websockets/connect"); + new("https://amer.botapi.skype.com/v3/websockets/connect"); private static readonly Uri SignalRUri = - new("https://signalr.example.test/client"); + new("https://signalr.example.test/client?hub=teams"); [Fact] public async Task StartAsync_NegotiatesCreatesAndStartsConnection() diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs index e90db095..da6d12f8 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs @@ -34,9 +34,9 @@ public async Task Transport_ComposesNegotiatorAndSignalRFactoryAcrossGeos() Assert.Equal(SocketModeStatus.Ready, stack.Transport.Status); Assert.Equal( [ - "https://botapi.skype.com/amer/v3/websockets/connect", - "https://botapi.skype.com/apac/v3/websockets/connect", - "https://botapi.skype.com/emea/v3/websockets/connect", + "https://amer.botapi.skype.com/v3/websockets/connect", + "https://apac.botapi.skype.com/v3/websockets/connect", + "https://emea.botapi.skype.com/v3/websockets/connect", ], stack.Http.Requests.Select(request => request.AbsoluteUri).Order(StringComparer.Ordinal)); Assert.All(stack.Http.Authorizations, authorization => Assert.Equal("Bearer bot-token", authorization)); @@ -168,7 +168,7 @@ protected override Task SendAsync( return Task.FromResult(throttled); } - string geo = uri.Segments[1].TrimEnd('/'); + string geo = uri.Host.Split('.')[0]; string json = $$"""{"url":"https://signalr.test/{{geo}}","accessToken":"token-{{geo}}","expiresIn":3600}"""; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs index 13a2bbdd..8168ae5b 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs @@ -94,9 +94,11 @@ public void WithSocketMode_AcceptsPublicIssuerWithTrailingSlash() public static TheoryData> InvalidOptions => new() { - o => o.Geos = [], + o => o.Geos = null!, + o => o.Geos = [""], + o => o.Geos = ["amer", ""], o => o.Geos = ["amer", null!], - o => o.Geos = ["amer", " AMER/"], + o => o.Geos = ["amer", "amer"], }; [Theory] @@ -135,7 +137,7 @@ public async Task Negotiator_UsesTheBotTokenForTheBotFrameworkScopeOnAPlainClien using ServiceProvider provider = services.BuildServiceProvider(); SocketModeNegotiateResponse response = await provider.GetRequiredService() - .NegotiateAsync(new Uri("https://botapi.skype.com/amer/v3/websockets/connect")); + .NegotiateAsync(new Uri("https://amer.botapi.skype.com/v3/websockets/connect")); Assert.Equal("signalr-token", response.AccessToken); Assert.Equal(["https://api.botframework.com/.default"], scopes); @@ -165,7 +167,7 @@ public async Task HostedService_StartsEveryGeoDispatchesToTheAppAndStops() SocketModeTransport transport = provider.GetRequiredService(); Assert.Equal(SocketModeStatus.Idle, socket.Status); Assert.Equal(["amer", "emea"], socket.GeoList); - Assert.Equal(new Uri("https://botapi.skype.com/amer/v3/websockets/connect"), socket.NegotiateUrl); + Assert.Equal(new Uri("https://amer.botapi.skype.com/v3/websockets/connect"), socket.NegotiateUrl); Assert.Empty(socket.GeoStatuses); await hosted.StartAsync(CancellationToken.None); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeNegotiatorTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeNegotiatorTests.cs index e2c5b3da..06e9eb73 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeNegotiatorTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeNegotiatorTests.cs @@ -11,30 +11,12 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode; public class SocketModeNegotiatorTests { private const string BotToken = "bot-token"; - private static readonly Uri NegotiateUri = new("https://botapi.skype.com/amer/v3/websockets/connect"); + private static readonly Uri NegotiateUri = new("https://amer.botapi.skype.com/v3/websockets/connect"); [Theory] - [InlineData("https://botapi.skype.com/v3/websockets/connect")] - [InlineData("http://localhost:5000/v3/websockets/connect")] - [InlineData("http://127.0.0.1:5000/v3/websockets/connect")] - [InlineData("http://[::1]:5000/v3/websockets/connect")] - public async Task NegotiateAsync_AllowsSecureAndLoopbackUris(string uri) - { - RecordingHandler handler = SuccessHandler(); - SocketModeNegotiator negotiator = CreateNegotiator(handler); - - SocketModeNegotiateResponse response = await negotiator.NegotiateAsync(new Uri(uri)); - - Assert.Equal("https://signalr.example.test/client", response.Url); - Assert.Equal(1, handler.SendCount); - } - - [Theory] - [InlineData("http://botapi.skype.com/v3/websockets/connect")] - [InlineData("http://127.0.0.2/v3/websockets/connect")] - [InlineData("wss://botapi.skype.com/v3/websockets/connect")] - [InlineData("ws://localhost:5000/v3/websockets/connect")] - [InlineData("ftp://botapi.skype.com/v3/websockets/connect")] + [InlineData("http://amer.botapi.skype.com/v3/websockets/connect")] + [InlineData("wss://amer.botapi.skype.com/v3/websockets/connect")] + [InlineData("ftp://amer.botapi.skype.com/v3/websockets/connect")] public async Task NegotiateAsync_RejectsUnsupportedNegotiateUris(string uri) { RecordingHandler handler = SuccessHandler(); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs index 96fb6f3d..4549517e 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs @@ -16,8 +16,7 @@ public async Task Diagnostics_AreLazyAndDoNotConnect() await using SocketModeTransport transport = new( new SocketModeTransportOptions { - NegotiateBaseUri = new Uri("https://canary.botapi.skype.com/"), - Geos = [" /eu/ "], + Geos = ["emea"], }, new UnusedConnectionFactory(), _ => Task.FromResult(new SocketDispatchResult(200)), @@ -32,8 +31,8 @@ public async Task Diagnostics_AreLazyAndDoNotConnect() observer.Events.Ready += (_, _) => { }; Assert.Equal(0, created); Assert.Equal(SocketModeStatus.Idle, observer.Status); - Assert.Equal(new Uri("https://canary.botapi.skype.com/eu/v3/websockets/connect"), observer.NegotiateUrl); - Assert.Equal(["eu"], observer.GeoList); + Assert.Equal(new Uri("https://emea.botapi.skype.com/v3/websockets/connect"), observer.NegotiateUrl); + Assert.Equal(["emea"], observer.GeoList); Assert.Empty(observer.GeoStatuses); Assert.Equal(1, created); } diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeOptionsTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeOptionsTests.cs index 01a2167a..ced706ef 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeOptionsTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeOptionsTests.cs @@ -8,13 +8,13 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode; public class SocketModeOptionsTests { [Fact] - public void Properties_ContainOnlyNegotiateBaseUrlAndGeos() + public void Properties_ContainOnlyGeos() { var properties = typeof(SocketModeOptions).GetProperties( System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); Assert.Equal( - [nameof(SocketModeOptions.Geos), nameof(SocketModeOptions.NegotiateBaseUrl)], + [nameof(SocketModeOptions.Geos)], properties.Select(property => property.Name).OrderBy(name => name, StringComparer.Ordinal)); Assert.All(properties, property => { @@ -28,7 +28,6 @@ public void Defaults_MatchTheOtherSdks() { SocketModeOptions options = new(); - Assert.Equal(new Uri("https://botapi.skype.com"), options.NegotiateBaseUrl); Assert.Equal(["amer", "emea", "apac"], options.Geos); SocketModeTransportOptions transport = options.ToTransportOptions(); Assert.Equal(TimeSpan.FromSeconds(30), transport.StartupTimeout); @@ -45,19 +44,17 @@ public void ToTransportOptions_PassesInvalidValuesThroughForTheTransportToReject } [Fact] - public void ToTransportOptions_CopiesEndpointAndSnapshotsGeos() + public void ToTransportOptions_SnapshotsGeos() { List geos = ["emea"]; SocketModeOptions options = new() { - NegotiateBaseUrl = new Uri("https://example.test"), Geos = geos, }; SocketModeTransportOptions transport = options.ToTransportOptions(); geos.Add("apac"); - Assert.Equal(new Uri("https://example.test"), transport.NegotiateBaseUri); Assert.Equal(["emea"], transport.Geos); } diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index d6f92f53..8366da10 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -16,19 +16,21 @@ public class SocketModeTransportTests { private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000); - [Fact] - public async Task StartAsync_ConnectsEveryDefaultGeo() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task StartAsync_ConnectsEveryDefaultGeo(bool emptyGeos) { - Harness harness = new(); + Harness harness = new(emptyGeos ? new SocketModeTransportOptions { Geos = [] } : null); Task start = harness.Transport.StartAsync(); FakeConnection[] connections = await harness.Factory.WaitForAsync(3); Assert.Equal( [ - "https://botapi.skype.com/amer/v3/websockets/connect", - "https://botapi.skype.com/apac/v3/websockets/connect", - "https://botapi.skype.com/emea/v3/websockets/connect", + "https://amer.botapi.skype.com/v3/websockets/connect", + "https://apac.botapi.skype.com/v3/websockets/connect", + "https://emea.botapi.skype.com/v3/websockets/connect", ], SortedUris(connections)); Assert.False(start.IsCompleted); @@ -42,40 +44,48 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() await start; Assert.Equal(SocketModeStatus.Ready, harness.Transport.Status); Assert.All(harness.Transport.GeoStatuses.Values, status => Assert.Equal(SocketModeStatus.Ready, status)); + await harness.Transport.DisposeAsync(); } - [Fact] - public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() + [Theory] + [InlineData(new object[] { new[] { "emea" } })] + [InlineData(new object[] { new[] { "custom-geo", "other-geo" } })] + [InlineData(new object[] { new[] { "amer", "AMER" } })] + [InlineData(new object[] { new[] { "amer.amer" } })] + public async Task StartAsync_ConnectsConfiguredGeos(string[] geos) { Harness harness = new(new SocketModeTransportOptions { - NegotiateBaseUri = new Uri("http://localhost:3978/"), - Geos = ["", " /eu/ "], + Geos = geos, }); - Task start = harness.Transport.StartAsync(); - FakeConnection[] connections = await harness.Factory.WaitForAsync(2); + FakeConnection[] connections = await harness.StartReadyAsync(); Assert.Equal( - [ - "http://localhost:3978/eu/v3/websockets/connect", - "http://localhost:3978/v3/websockets/connect", - ], + geos.Select(geo => $"https://{geo.ToLowerInvariant()}.botapi.skype.com/v3/websockets/connect").Order(StringComparer.Ordinal), SortedUris(connections)); - foreach (FakeConnection connection in connections) - { - connection.Ready(); - } - - await start; + Assert.Equal(geos, harness.Transport.GeoList); + Assert.Equal(geos.Length, harness.Transport.GeoStatuses.Count); + Assert.All(harness.Transport.GeoStatuses.Values, status => Assert.Equal(SocketModeStatus.Ready, status)); + await harness.Transport.DisposeAsync(); } [Theory] - [InlineData(new object[] { new string[0] })] - [InlineData(new object[] { new[] { "amer", "AMER" } })] - public void Constructor_RejectsInvalidGeos(string[] geos) + [InlineData(null)] + [InlineData(new object[] { new string[] { null! } })] + [InlineData(new object[] { new[] { "" } })] + [InlineData(new object[] { new[] { "amer", "" } })] + [InlineData(new object[] { new[] { "amer", "amer" } })] + public void Constructor_RejectsInvalidGeos(string[]? geos) + { + Assert.Throws(() => new Harness(new SocketModeTransportOptions { Geos = geos! }).Transport); + } + + [Fact] + public void Constructor_DoesNotInterpretGeoAsUrlSyntax() { - Assert.Throws(() => new Harness(new SocketModeTransportOptions { Geos = geos }).Transport); + Assert.Throws(() => + new Harness(new SocketModeTransportOptions { Geos = ["attacker.example#"] }).Transport); } [Fact] @@ -891,8 +901,7 @@ internal async Task WaitForAsync(int count) public ISocketConnection Create(Uri negotiateUri, SocketConnectionHandlers handlers, Func? getReplacedConnectionId = null) { - string[] segments = negotiateUri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); - string geo = segments.Length > 3 ? segments[0] : string.Empty; + string geo = negotiateUri.Host.Split('.')[0]; FakeConnection connection = new( negotiateUri, geo,