From c5ad448345530021472fe3cedfec9bb8781ea99c Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 10:26:49 -0800 Subject: [PATCH 01/15] Fix Socket Mode connect geo routing Use geo subdomains for production connect requests while preserving custom endpoint routing and authentication scopes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- samples/SocketModeBot/README.md | 2 +- src/Microsoft.Teams.Apps/README.md | 2 ++ .../SocketMode/SocketModeOptions.cs | 4 +++- .../SocketMode/SocketModeProtocol.cs | 2 +- .../SocketMode/SocketModeTransport.cs | 14 +++++++++-- .../SocketMode/SocketModeEndToEndTests.cs | 8 +++---- .../SocketMode/SocketModeHostingTests.cs | 2 +- .../SocketMode/SocketModeTransportTests.cs | 23 +++++++++++++++---- 8 files changed, 43 insertions(+), 14 deletions(-) diff --git a/samples/SocketModeBot/README.md b/samples/SocketModeBot/README.md index 2f024ae8..55a1d78a 100644 --- a/samples/SocketModeBot/README.md +++ b/samples/SocketModeBot/README.md @@ -9,7 +9,7 @@ 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`). +- `UseSocketMode()` on `AddTeamsBotApplication` to receive activities over Socket Mode using the production connect endpoints (`https://{geo}.botapi.skype.com/v3/websockets/connect`). - 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. - `$(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. diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index 2f6c29a6..b0d613af 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -215,6 +215,7 @@ host.Run(); - **One connection per geo** — The bot connects to `amer`, `emea`, and `apac` by default. Startup waits until every geo is ready and fails if any geo cannot connect within 30 seconds. Dropped connections reconnect automatically, and connection tokens are rotated before they expire. + Production connect requests use `https://{geo}.botapi.skype.com/v3/websockets/connect`. - **No web server** — `UseSocketMode` is the only switch; `UseTeamsBotApplication()` is the same call for both transports. With Socket Mode enabled it throws on a `WebApplication`, and a host that includes a web server fails to start. Tabs, OAuth callbacks, health endpoints, and other HTTP routes are unavailable. @@ -246,6 +247,7 @@ and `.IsAuthError` for HTTP 401/403. Downstream SignalR negotiation errors are n service-negotiation rejections. `UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `NegotiateBaseUrl` and `Geos`. +Custom base hosts retain geo path segments; an empty geo uses the base URL without a geo subdomain or path segment. 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/SocketModeOptions.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs index afdac15b..53fc558a 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs @@ -22,12 +22,14 @@ 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. + /// Connections to botapi.skype.com use the geo as a subdomain; custom hosts use a geo path segment. /// 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. + /// subdomain or path segment. Defaults to amer, emea, and apac. + /// Override this to connect to a different set of geos. /// public IReadOnlyList Geos { get; set; } = SocketModeProtocol.DefaultGeos; diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs index 675df4ba..c13e388b 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs @@ -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..8229b06b 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs @@ -53,7 +53,7 @@ internal sealed class SocketModeTransportOptions 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 string connects without a geo subdomain or path segment. /// internal IReadOnlyList Geos { get; init; } = SocketModeProtocol.DefaultGeos; @@ -499,7 +499,7 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ if (geos is null || geos.Count == 0) { throw new ArgumentException( - "Socket Mode requires at least one geo. Use an empty string to connect without a geo segment.", + "Socket Mode requires at least one geo. Use an empty string to connect without a geo subdomain or path segment.", nameof(geos)); } @@ -520,6 +520,16 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ throw new ArgumentException($"Socket Mode geo '{geo}' is listed more than once.", nameof(geos)); } + if (segment.Length > 0 && baseUri.Host.Equals("botapi.skype.com", StringComparison.OrdinalIgnoreCase)) + { + UriBuilder builder = new(baseUrl + SocketModeProtocol.NegotiatePath) + { + Host = $"{segment}.{baseUri.Host}", + }; + resolved.Add((segment, builder.Uri)); + continue; + } + string path = segment.Length > 0 ? $"/{Uri.EscapeDataString(segment)}{SocketModeProtocol.NegotiatePath}" : SocketModeProtocol.NegotiatePath; 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..fe05d6aa 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs @@ -165,7 +165,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/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index d6f92f53..6faf1d15 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -26,9 +26,9 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() 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); @@ -44,6 +44,19 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() Assert.All(harness.Transport.GeoStatuses.Values, status => Assert.Equal(SocketModeStatus.Ready, status)); } + [Fact] + public async Task StartAsync_EmptyGeoUsesProductionBaseWithoutSubdomain() + { + Harness harness = new(new SocketModeTransportOptions { Geos = [""] }); + + Task start = harness.Transport.StartAsync(); + FakeConnection connection = Assert.Single(await harness.Factory.WaitForAsync(1)); + + Assert.Equal(new Uri("https://botapi.skype.com/v3/websockets/connect"), connection.NegotiateUri); + connection.Ready(); + await start; + } + [Fact] public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() { @@ -892,7 +905,9 @@ 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.EndsWith(".botapi.skype.com", StringComparison.OrdinalIgnoreCase) + ? negotiateUri.Host.Split('.')[0] + : segments.Length > 3 ? segments[0] : string.Empty; FakeConnection connection = new( negotiateUri, geo, From 22af4280db1d209048dd181e4824883f7d869f84 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 10:30:27 -0800 Subject: [PATCH 02/15] Log negotiated SignalR endpoints per geo at debug level Include the geo and SignalR endpoint on each connection attempt, omitting URL credentials, query strings, and fragments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Teams.Apps/README.md | 3 ++ .../SocketMode/GeoSocket.cs | 19 ++++++++- .../SocketMode/SignalRSocketConnection.cs | 5 ++- .../SocketMode/SocketModeConnection.cs | 6 +-- .../SocketMode/GeoSocketTests.cs | 40 ++++++++++++++++++- .../SignalRSocketConnectionTests.cs | 23 +++++++++++ 6 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index b0d613af..c1590eeb 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -248,6 +248,9 @@ service-negotiation rejections. `UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `NegotiateBaseUrl` and `Geos`. Custom base hosts retain geo path segments; an empty geo uses the base URL without a geo subdomain or path segment. +Enable Debug logging for `Microsoft.Teams.Apps.SocketMode.SocketModeTransport` to see each geo's negotiated +SignalR endpoint on initial connection, reconnect, and token rotation. Credentials, query strings, and fragments +are omitted from the logged URL. 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..c17d591a 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs @@ -371,7 +371,24 @@ private async Task ConnectAsync(Generation? replacing, CancellationT envelope => DispatchAsync(generation, envelope), frame => HandleReady(generation, frame), (error, planned) => HandleClosed(generation, error, planned), - () => generation.TokenIssuedAt = _timeProvider.GetTimestamp()), + signalRUri => + { + generation.TokenIssuedAt = _timeProvider.GetTimestamp(); + if (_logger.IsEnabled(LogLevel.Debug)) + { + UriBuilder endpoint = new(signalRUri) + { + UserName = string.Empty, + Password = string.Empty, + Query = string.Empty, + Fragment = string.Empty, + }; + _logger.LogDebug( + "Socket Mode geo {Geo} connecting to SignalR endpoint {SignalRUrl}.", + Geo, + endpoint.Uri.AbsoluteUri); + } + }), getReplacedConnectionId); bool owned; diff --git a/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs b/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs index e1ad2839..c9220a1b 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs @@ -143,14 +143,15 @@ public async Task StartAsync(CancellationToken cancellationToken) await _negotiator .NegotiateAsync(_negotiateUri, _getReplacedConnectionId, startSource.Token) .ConfigureAwait(false); - _handlers.OnNegotiated?.Invoke(); + Uri signalRUri = new(negotiateResponse.Url!, UriKind.Absolute); + _handlers.OnNegotiated?.Invoke(signalRUri); TokenLifetime = negotiateResponse.ExpiresIn > 0 ? TimeSpan.FromSeconds(negotiateResponse.ExpiresIn) : null; ISignalRClientConnection connection = _createSignalRClientConnection( - new Uri(negotiateResponse.Url!, UriKind.Absolute), + signalRUri, negotiateResponse.AccessToken!); if (!TryPublishConnection(connection)) { diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs index cc98e6ea..c3c76f7d 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs @@ -59,12 +59,12 @@ internal sealed class SocketConnectionHandlers /// Handles an incoming activity envelope. /// Handles the ready frame. /// Handles connection closure and indicates whether it was planned. - /// Runs when negotiate succeeds, which is when the token lifetime starts. + /// Receives the SignalR URL when negotiate succeeds, which is when the token lifetime starts. internal SocketConnectionHandlers( Func> onActivity, Action onReady, Action onClosed, - Action? onNegotiated = null) + Action? onNegotiated = null) { OnActivity = onActivity ?? throw new ArgumentNullException(nameof(onActivity)); OnReady = onReady ?? throw new ArgumentNullException(nameof(onReady)); @@ -90,5 +90,5 @@ internal SocketConnectionHandlers( /// /// Gets the callback that runs when negotiate succeeds. /// - internal Action? OnNegotiated { get; } + internal Action? OnNegotiated { get; } } diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs index 8ac4ff59..97a87146 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs @@ -31,6 +31,21 @@ public async Task StartAsync_CompletesOnlyAfterSocketReady() Assert.Empty(harness.Owner.Disconnections); } + [Fact] + public async Task StartAsync_LogsGeoAndSignalREndpointWithoutSecrets() + { + Harness harness = new(); + FakeConnection connection = harness.Factory.Enqueue(); + connection.SignalRUri = new Uri("https://user:password@signalr.example.test:8443/client?access_token=secret#fragment"); + + await harness.StartReadyAsync(connection); + + Assert.Equal( + "Socket Mode geo amer connecting to SignalR endpoint https://signalr.example.test:8443/client.", + Assert.Single(harness.Logger.DebugMessages)); + await harness.Socket.DisposeAsync(); + } + [Fact] public async Task StartAsync_RetriesWithBackoffWithinBudget() { @@ -1276,6 +1291,8 @@ private sealed class FakeConnection(TimeSpan? tokenLifetime, Exception? startErr internal SocketConnectionHandlers Handlers { get; set; } = null!; + internal Uri SignalRUri { get; set; } = new("https://signalr.example.test/client"); + internal Func? GetReplacedConnectionId { get; set; } /// @@ -1313,7 +1330,7 @@ public async Task StartAsync(CancellationToken cancellationToken) ReplacesConnectionId = GetReplacedConnectionId?.Invoke(); if (startError is null) { - Handlers.OnNegotiated?.Invoke(); + Handlers.OnNegotiated?.Invoke(SignalRUri); } Started.TrySetResult(); @@ -1376,9 +1393,21 @@ private void RaiseClosed(Exception? error, bool planned) private sealed class RecordingLogger : ILogger { + private readonly List _debugMessages = []; private readonly List<(string Message, Exception? Exception)> _warnings = []; private readonly List _errors = []; + internal string[] DebugMessages + { + get + { + lock (_debugMessages) + { + return [.. _debugMessages]; + } + } + } + internal Exception?[] Errors { get @@ -1413,7 +1442,14 @@ public void Log( Exception? exception, Func formatter) { - if (logLevel == LogLevel.Warning) + if (logLevel == LogLevel.Debug) + { + lock (_debugMessages) + { + _debugMessages.Add(formatter(state, exception)); + } + } + else if (logLevel == LogLevel.Warning) { lock (_warnings) { diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs index 44703549..50fb1d60 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs @@ -37,6 +37,29 @@ public async Task StartAsync_NegotiatesCreatesAndStartsConnection() Assert.Single(harness.ReadyFrames); } + [Fact] + public async Task StartAsync_ReportsTheNegotiatedSignalRUrl() + { + FakeNegotiator negotiator = new(SuccessfulNegotiation()); + FakeSignalRClientConnection signalR = new(); + FakeSignalRConnectionFactory signalRFactory = new(signalR); + Uri? negotiatedUrl = null; + SocketConnectionHandlers handlers = new( + _ => Task.FromResult(null), + _ => { }, + (_, _) => { }, + url => negotiatedUrl = url); + await using SignalRSocketConnection connection = CreateConnection(negotiator, signalRFactory, handlers); + + Task start = connection.StartAsync(CancellationToken.None); + await signalR.Started.Task; + + Assert.Equal(SignalRUri, negotiatedUrl); + Assert.Equal(signalRFactory.Url, negotiatedUrl); + signalR.Ready(new SocketReadyFrame()); + await start; + } + [Fact] public async Task StartAsync_DoesNotCompleteBeforeSocketReady() { From cef68aaa08b0a0b10cd41dea0bd2d309c274660b Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 10:32:19 -0800 Subject: [PATCH 03/15] Log SignalR endpoints at Information level Show per-geo connection URLs with the existing logging configuration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Teams.Apps/README.md | 4 ++-- .../SocketMode/GeoSocket.cs | 4 ++-- .../SocketMode/GeoSocketTests.cs | 20 +++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index c1590eeb..8a4fea1a 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -248,8 +248,8 @@ service-negotiation rejections. `UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `NegotiateBaseUrl` and `Geos`. Custom base hosts retain geo path segments; an empty geo uses the base URL without a geo subdomain or path segment. -Enable Debug logging for `Microsoft.Teams.Apps.SocketMode.SocketModeTransport` to see each geo's negotiated -SignalR endpoint on initial connection, reconnect, and token rotation. Credentials, query strings, and fragments +Each geo's negotiated SignalR endpoint is logged at Information level on initial connection, reconnect, +and token rotation. Credentials, query strings, and fragments are omitted from the logged URL. 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. diff --git a/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs b/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs index c17d591a..7cd630d6 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs @@ -374,7 +374,7 @@ private async Task ConnectAsync(Generation? replacing, CancellationT signalRUri => { generation.TokenIssuedAt = _timeProvider.GetTimestamp(); - if (_logger.IsEnabled(LogLevel.Debug)) + if (_logger.IsEnabled(LogLevel.Information)) { UriBuilder endpoint = new(signalRUri) { @@ -383,7 +383,7 @@ private async Task ConnectAsync(Generation? replacing, CancellationT Query = string.Empty, Fragment = string.Empty, }; - _logger.LogDebug( + _logger.LogInformation( "Socket Mode geo {Geo} connecting to SignalR endpoint {SignalRUrl}.", Geo, endpoint.Uri.AbsoluteUri); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs index 97a87146..4d71c92e 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs @@ -32,7 +32,7 @@ public async Task StartAsync_CompletesOnlyAfterSocketReady() } [Fact] - public async Task StartAsync_LogsGeoAndSignalREndpointWithoutSecrets() + public async Task StartAsync_LogsGeoAndSignalREndpointAtInformationWithoutSecrets() { Harness harness = new(); FakeConnection connection = harness.Factory.Enqueue(); @@ -42,7 +42,7 @@ public async Task StartAsync_LogsGeoAndSignalREndpointWithoutSecrets() Assert.Equal( "Socket Mode geo amer connecting to SignalR endpoint https://signalr.example.test:8443/client.", - Assert.Single(harness.Logger.DebugMessages)); + Assert.Single(harness.Logger.InformationMessages)); await harness.Socket.DisposeAsync(); } @@ -1393,17 +1393,17 @@ private void RaiseClosed(Exception? error, bool planned) private sealed class RecordingLogger : ILogger { - private readonly List _debugMessages = []; + private readonly List _informationMessages = []; private readonly List<(string Message, Exception? Exception)> _warnings = []; private readonly List _errors = []; - internal string[] DebugMessages + internal string[] InformationMessages { get { - lock (_debugMessages) + lock (_informationMessages) { - return [.. _debugMessages]; + return [.. _informationMessages]; } } } @@ -1433,7 +1433,7 @@ internal Exception?[] Errors public IDisposable? BeginScope(TState state) where TState : notnull => null; - public bool IsEnabled(LogLevel logLevel) => true; + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; public void Log( LogLevel logLevel, @@ -1442,11 +1442,11 @@ public void Log( Exception? exception, Func formatter) { - if (logLevel == LogLevel.Debug) + if (logLevel == LogLevel.Information) { - lock (_debugMessages) + lock (_informationMessages) { - _debugMessages.Add(formatter(state, exception)); + _informationMessages.Add(formatter(state, exception)); } } else if (logLevel == LogLevel.Warning) From c9035166df63b5f6ad3b578d9a2c8684709af51f Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 11:13:36 -0800 Subject: [PATCH 04/15] Align Socket Mode geo routing and diagnostics with Python Use geo subdomains for custom connect hosts, preserving base paths, ports, and queries. Log resolved negotiate URLs at Debug during startup instead of logging returned SignalR endpoints at Information. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Teams.Apps/README.md | 7 ++- .../SocketMode/GeoSocket.cs | 19 +------- .../SocketMode/SignalRSocketConnection.cs | 5 +-- .../SocketMode/SocketModeConnection.cs | 6 +-- .../SocketMode/SocketModeOptions.cs | 4 +- .../SocketMode/SocketModeTransport.cs | 32 ++++++++------ .../SocketMode/GeoSocketTests.cs | 42 ++---------------- .../SignalRSocketConnectionTests.cs | 11 +++-- .../SocketMode/SocketModeObserverTests.cs | 2 +- .../SocketMode/SocketModeTransportTests.cs | 44 ++++++++++++++++--- 10 files changed, 78 insertions(+), 94 deletions(-) diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index 8a4fea1a..86fb3b10 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -247,10 +247,9 @@ and `.IsAuthError` for HTTP 401/403. Downstream SignalR negotiation errors are n service-negotiation rejections. `UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `NegotiateBaseUrl` and `Geos`. -Custom base hosts retain geo path segments; an empty geo uses the base URL without a geo subdomain or path segment. -Each geo's negotiated SignalR endpoint is logged at Information level on initial connection, reconnect, -and token rotation. Credentials, query strings, and fragments -are omitted from the logged URL. +Custom base hosts also use geo subdomains, preserving any existing base path; an empty geo uses the base URL +without a geo subdomain. Each geo's resolved connect/negotiate URL is logged at Debug 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 7cd630d6..ce211c99 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs @@ -371,24 +371,7 @@ private async Task ConnectAsync(Generation? replacing, CancellationT envelope => DispatchAsync(generation, envelope), frame => HandleReady(generation, frame), (error, planned) => HandleClosed(generation, error, planned), - signalRUri => - { - generation.TokenIssuedAt = _timeProvider.GetTimestamp(); - if (_logger.IsEnabled(LogLevel.Information)) - { - UriBuilder endpoint = new(signalRUri) - { - UserName = string.Empty, - Password = string.Empty, - Query = string.Empty, - Fragment = string.Empty, - }; - _logger.LogInformation( - "Socket Mode geo {Geo} connecting to SignalR endpoint {SignalRUrl}.", - Geo, - endpoint.Uri.AbsoluteUri); - } - }), + () => generation.TokenIssuedAt = _timeProvider.GetTimestamp()), getReplacedConnectionId); bool owned; diff --git a/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs b/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs index c9220a1b..e1ad2839 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs @@ -143,15 +143,14 @@ public async Task StartAsync(CancellationToken cancellationToken) await _negotiator .NegotiateAsync(_negotiateUri, _getReplacedConnectionId, startSource.Token) .ConfigureAwait(false); - Uri signalRUri = new(negotiateResponse.Url!, UriKind.Absolute); - _handlers.OnNegotiated?.Invoke(signalRUri); + _handlers.OnNegotiated?.Invoke(); TokenLifetime = negotiateResponse.ExpiresIn > 0 ? TimeSpan.FromSeconds(negotiateResponse.ExpiresIn) : null; ISignalRClientConnection connection = _createSignalRClientConnection( - signalRUri, + new Uri(negotiateResponse.Url!, UriKind.Absolute), negotiateResponse.AccessToken!); if (!TryPublishConnection(connection)) { diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs index c3c76f7d..cc98e6ea 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs @@ -59,12 +59,12 @@ internal sealed class SocketConnectionHandlers /// Handles an incoming activity envelope. /// Handles the ready frame. /// Handles connection closure and indicates whether it was planned. - /// Receives the SignalR URL when negotiate succeeds, which is when the token lifetime starts. + /// Runs when negotiate succeeds, which is when the token lifetime starts. internal SocketConnectionHandlers( Func> onActivity, Action onReady, Action onClosed, - Action? onNegotiated = null) + Action? onNegotiated = null) { OnActivity = onActivity ?? throw new ArgumentNullException(nameof(onActivity)); OnReady = onReady ?? throw new ArgumentNullException(nameof(onReady)); @@ -90,5 +90,5 @@ internal SocketConnectionHandlers( /// /// Gets the callback that runs when negotiate succeeds. /// - internal Action? OnNegotiated { get; } + internal Action? OnNegotiated { get; } } diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs index 53fc558a..7ee45088 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs @@ -22,13 +22,13 @@ 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. - /// Connections to botapi.skype.com use the geo as a subdomain; custom hosts use a geo path segment. + /// Connections use the geo as a subdomain, including for custom hosts, and preserve the base URL's path. /// 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 - /// subdomain or path segment. Defaults to amer, emea, and apac. + /// subdomain. Defaults to amer, emea, and apac. /// Override this to connect to a different set of geos. /// public IReadOnlyList Geos { get; set; } = SocketModeProtocol.DefaultGeos; diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs index 8229b06b..96d69099 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs @@ -53,7 +53,7 @@ internal sealed class SocketModeTransportOptions internal Uri NegotiateBaseUri { get; init; } = new(SocketModeProtocol.DefaultNegotiateBaseUrl); /// - /// Gets the geos to connect. An empty string connects without a geo subdomain or path segment. + /// Gets the geos to connect. An empty string connects without a geo subdomain. /// internal IReadOnlyList Geos { get; init; } = SocketModeProtocol.DefaultGeos; @@ -241,6 +241,14 @@ internal async Task StartAsync(CancellationToken cancellationToken = default) geoSockets.Length, string.Join(", ", geoSockets.Select(geoSocket => geoSocket.Geo))); + foreach (var geo in _geos) + { + _logger.LogDebug( + "Socket Mode geo {Geo} negotiate URL {NegotiateUrl}.", + geo.Geo.Length > 0 ? geo.Geo : "default", + geo.NegotiateUri); + } + using CancellationTokenSource startSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); Exception? firstFailure = null; @@ -499,11 +507,10 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ if (geos is null || geos.Count == 0) { throw new ArgumentException( - "Socket Mode requires at least one geo. Use an empty string to connect without a geo subdomain or path segment.", + "Socket Mode requires at least one geo. Use an empty string to connect without a geo subdomain.", nameof(geos)); } - string baseUrl = baseUri.AbsoluteUri.TrimEnd('/'); HashSet seen = new(StringComparer.OrdinalIgnoreCase); List<(string Geo, Uri NegotiateUri)> resolved = []; @@ -520,20 +527,19 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ throw new ArgumentException($"Socket Mode geo '{geo}' is listed more than once.", nameof(geos)); } - if (segment.Length > 0 && baseUri.Host.Equals("botapi.skype.com", StringComparison.OrdinalIgnoreCase)) + if (segment.Length == 0) { - UriBuilder builder = new(baseUrl + SocketModeProtocol.NegotiatePath) - { - Host = $"{segment}.{baseUri.Host}", - }; - resolved.Add((segment, builder.Uri)); + resolved.Add((segment, new Uri(baseUri.AbsoluteUri.TrimEnd('/') + SocketModeProtocol.NegotiatePath, UriKind.Absolute))); continue; } - string path = segment.Length > 0 - ? $"/{Uri.EscapeDataString(segment)}{SocketModeProtocol.NegotiatePath}" - : SocketModeProtocol.NegotiatePath; - resolved.Add((segment, new Uri(baseUrl + path, UriKind.Absolute))); + UriBuilder builder = new(baseUri) + { + Host = $"{segment}.{baseUri.Host}", + Path = baseUri.AbsolutePath.TrimEnd('/') + SocketModeProtocol.NegotiatePath, + Fragment = string.Empty, + }; + resolved.Add((segment, 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 4d71c92e..8ac4ff59 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs @@ -31,21 +31,6 @@ public async Task StartAsync_CompletesOnlyAfterSocketReady() Assert.Empty(harness.Owner.Disconnections); } - [Fact] - public async Task StartAsync_LogsGeoAndSignalREndpointAtInformationWithoutSecrets() - { - Harness harness = new(); - FakeConnection connection = harness.Factory.Enqueue(); - connection.SignalRUri = new Uri("https://user:password@signalr.example.test:8443/client?access_token=secret#fragment"); - - await harness.StartReadyAsync(connection); - - Assert.Equal( - "Socket Mode geo amer connecting to SignalR endpoint https://signalr.example.test:8443/client.", - Assert.Single(harness.Logger.InformationMessages)); - await harness.Socket.DisposeAsync(); - } - [Fact] public async Task StartAsync_RetriesWithBackoffWithinBudget() { @@ -1291,8 +1276,6 @@ private sealed class FakeConnection(TimeSpan? tokenLifetime, Exception? startErr internal SocketConnectionHandlers Handlers { get; set; } = null!; - internal Uri SignalRUri { get; set; } = new("https://signalr.example.test/client"); - internal Func? GetReplacedConnectionId { get; set; } /// @@ -1330,7 +1313,7 @@ public async Task StartAsync(CancellationToken cancellationToken) ReplacesConnectionId = GetReplacedConnectionId?.Invoke(); if (startError is null) { - Handlers.OnNegotiated?.Invoke(SignalRUri); + Handlers.OnNegotiated?.Invoke(); } Started.TrySetResult(); @@ -1393,21 +1376,9 @@ private void RaiseClosed(Exception? error, bool planned) private sealed class RecordingLogger : ILogger { - private readonly List _informationMessages = []; private readonly List<(string Message, Exception? Exception)> _warnings = []; private readonly List _errors = []; - internal string[] InformationMessages - { - get - { - lock (_informationMessages) - { - return [.. _informationMessages]; - } - } - } - internal Exception?[] Errors { get @@ -1433,7 +1404,7 @@ internal Exception?[] Errors public IDisposable? BeginScope(TState state) where TState : notnull => null; - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; + public bool IsEnabled(LogLevel logLevel) => true; public void Log( LogLevel logLevel, @@ -1442,14 +1413,7 @@ public void Log( Exception? exception, Func formatter) { - if (logLevel == LogLevel.Information) - { - lock (_informationMessages) - { - _informationMessages.Add(formatter(state, exception)); - } - } - else if (logLevel == LogLevel.Warning) + if (logLevel == LogLevel.Warning) { lock (_warnings) { diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs index 50fb1d60..43a98d47 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs @@ -11,7 +11,7 @@ public class SignalRSocketConnectionTests private static readonly Uri NegotiateUri = new("https://botapi.skype.com/amer/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() @@ -38,24 +38,23 @@ public async Task StartAsync_NegotiatesCreatesAndStartsConnection() } [Fact] - public async Task StartAsync_ReportsTheNegotiatedSignalRUrl() + public async Task StartAsync_ReportsSuccessfulNegotiation() { FakeNegotiator negotiator = new(SuccessfulNegotiation()); FakeSignalRClientConnection signalR = new(); FakeSignalRConnectionFactory signalRFactory = new(signalR); - Uri? negotiatedUrl = null; + bool negotiated = false; SocketConnectionHandlers handlers = new( _ => Task.FromResult(null), _ => { }, (_, _) => { }, - url => negotiatedUrl = url); + () => negotiated = true); await using SignalRSocketConnection connection = CreateConnection(negotiator, signalRFactory, handlers); Task start = connection.StartAsync(CancellationToken.None); await signalR.Started.Task; - Assert.Equal(SignalRUri, negotiatedUrl); - Assert.Equal(signalRFactory.Url, negotiatedUrl); + Assert.True(negotiated); signalR.Ready(new SocketReadyFrame()); await start; } diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs index 96fb6f3d..acbd6714 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs @@ -32,7 +32,7 @@ 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(new Uri("https://eu.canary.botapi.skype.com/v3/websockets/connect"), observer.NegotiateUrl); Assert.Equal(["eu"], observer.GeoList); Assert.Empty(observer.GeoStatuses); Assert.Equal(1, created); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index 6faf1d15..66509457 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -19,7 +19,8 @@ public class SocketModeTransportTests [Fact] public async Task StartAsync_ConnectsEveryDefaultGeo() { - Harness harness = new(); + ErrorLogger logger = new(); + Harness harness = new() { Logger = logger }; Task start = harness.Transport.StartAsync(); FakeConnection[] connections = await harness.Factory.WaitForAsync(3); @@ -31,6 +32,14 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() "https://emea.botapi.skype.com/v3/websockets/connect", ], SortedUris(connections)); + Assert.Equal( + [ + "Socket Mode geo amer negotiate URL https://amer.botapi.skype.com/v3/websockets/connect.", + "Socket Mode geo apac negotiate URL https://apac.botapi.skype.com/v3/websockets/connect.", + "Socket Mode geo emea negotiate URL https://emea.botapi.skype.com/v3/websockets/connect.", + ], + logger.Records.Where(record => record.Level == LogLevel.Debug) + .Select(record => record.Message).Order(StringComparer.Ordinal)); Assert.False(start.IsCompleted); Assert.Equal(SocketModeStatus.Connecting, harness.Transport.Status); @@ -47,12 +56,16 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() [Fact] public async Task StartAsync_EmptyGeoUsesProductionBaseWithoutSubdomain() { - Harness harness = new(new SocketModeTransportOptions { Geos = [""] }); + ErrorLogger logger = new(); + Harness harness = new(new SocketModeTransportOptions { Geos = [""] }) { Logger = logger }; Task start = harness.Transport.StartAsync(); FakeConnection connection = Assert.Single(await harness.Factory.WaitForAsync(1)); Assert.Equal(new Uri("https://botapi.skype.com/v3/websockets/connect"), connection.NegotiateUri); + Assert.Equal( + "Socket Mode geo default negotiate URL https://botapi.skype.com/v3/websockets/connect.", + Assert.Single(logger.Records, record => record.Level == LogLevel.Debug).Message); connection.Ready(); await start; } @@ -62,7 +75,7 @@ public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() { Harness harness = new(new SocketModeTransportOptions { - NegotiateBaseUri = new Uri("http://localhost:3978/"), + NegotiateBaseUri = new Uri("https://botapi.example:3978/ring/"), Geos = ["", " /eu/ "], }); @@ -71,8 +84,8 @@ public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() Assert.Equal( [ - "http://localhost:3978/eu/v3/websockets/connect", - "http://localhost:3978/v3/websockets/connect", + "https://botapi.example:3978/ring/v3/websockets/connect", + "https://eu.botapi.example:3978/ring/v3/websockets/connect", ], SortedUris(connections)); foreach (FakeConnection connection in connections) @@ -83,6 +96,27 @@ public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() await start; } + [Theory] + [InlineData("https://botapi.example", "amer", "https://amer.botapi.example/v3/websockets/connect")] + [InlineData("https://botapi.example/ring/", " /eu/ ", "https://eu.botapi.example/ring/v3/websockets/connect")] + [InlineData("https://botapi.example:8443/ring/?route=test#fragment", "apac", "https://apac.botapi.example:8443/ring/v3/websockets/connect?route=test")] + [InlineData("http://localhost:3978/", "amer", "http://amer.localhost:3978/v3/websockets/connect")] + public async Task StartAsync_UsesGeoSubdomainsForCustomHosts(string baseUrl, string geo, string expectedUrl) + { + Harness harness = new(new SocketModeTransportOptions + { + NegotiateBaseUri = new Uri(baseUrl), + Geos = [geo], + }); + + Task start = harness.Transport.StartAsync(); + FakeConnection connection = Assert.Single(await harness.Factory.WaitForAsync(1)); + Assert.Equal(new Uri(expectedUrl), connection.NegotiateUri); + connection.Ready(); + await start; + await harness.Transport.DisposeAsync(); + } + [Theory] [InlineData(new object[] { new string[0] })] [InlineData(new object[] { new[] { "amer", "AMER" } })] From fd849d5a3bbf818944ce18e4b1141e3b76b49841 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 11:17:25 -0800 Subject: [PATCH 05/15] Remove redundant Socket Mode regression tests Drop the leftover callback test from the earlier SignalR logging approach and consolidate overlapping custom-host and empty-geo cases. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../SignalRSocketConnectionTests.cs | 22 ----------------- .../SocketMode/SocketModeTransportTests.cs | 24 ++++--------------- 2 files changed, 5 insertions(+), 41 deletions(-) diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs index 43a98d47..22adb4b8 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs @@ -37,28 +37,6 @@ public async Task StartAsync_NegotiatesCreatesAndStartsConnection() Assert.Single(harness.ReadyFrames); } - [Fact] - public async Task StartAsync_ReportsSuccessfulNegotiation() - { - FakeNegotiator negotiator = new(SuccessfulNegotiation()); - FakeSignalRClientConnection signalR = new(); - FakeSignalRConnectionFactory signalRFactory = new(signalR); - bool negotiated = false; - SocketConnectionHandlers handlers = new( - _ => Task.FromResult(null), - _ => { }, - (_, _) => { }, - () => negotiated = true); - await using SignalRSocketConnection connection = CreateConnection(negotiator, signalRFactory, handlers); - - Task start = connection.StartAsync(CancellationToken.None); - await signalR.Started.Task; - - Assert.True(negotiated); - signalR.Ready(new SocketReadyFrame()); - await start; - } - [Fact] public async Task StartAsync_DoesNotCompleteBeforeSocketReady() { diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index 66509457..f31d0ece 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -53,31 +53,15 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() Assert.All(harness.Transport.GeoStatuses.Values, status => Assert.Equal(SocketModeStatus.Ready, status)); } - [Fact] - public async Task StartAsync_EmptyGeoUsesProductionBaseWithoutSubdomain() - { - ErrorLogger logger = new(); - Harness harness = new(new SocketModeTransportOptions { Geos = [""] }) { Logger = logger }; - - Task start = harness.Transport.StartAsync(); - FakeConnection connection = Assert.Single(await harness.Factory.WaitForAsync(1)); - - Assert.Equal(new Uri("https://botapi.skype.com/v3/websockets/connect"), connection.NegotiateUri); - Assert.Equal( - "Socket Mode geo default negotiate URL https://botapi.skype.com/v3/websockets/connect.", - Assert.Single(logger.Records, record => record.Level == LogLevel.Debug).Message); - connection.Ready(); - await start; - } - [Fact] public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() { + ErrorLogger logger = new(); Harness harness = new(new SocketModeTransportOptions { NegotiateBaseUri = new Uri("https://botapi.example:3978/ring/"), Geos = ["", " /eu/ "], - }); + }) { Logger = logger }; Task start = harness.Transport.StartAsync(); FakeConnection[] connections = await harness.Factory.WaitForAsync(2); @@ -88,6 +72,9 @@ public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() "https://eu.botapi.example:3978/ring/v3/websockets/connect", ], SortedUris(connections)); + Assert.Contains(logger.Records, record => + record.Level == LogLevel.Debug + && record.Message == "Socket Mode geo default negotiate URL https://botapi.example:3978/ring/v3/websockets/connect."); foreach (FakeConnection connection in connections) { connection.Ready(); @@ -98,7 +85,6 @@ public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() [Theory] [InlineData("https://botapi.example", "amer", "https://amer.botapi.example/v3/websockets/connect")] - [InlineData("https://botapi.example/ring/", " /eu/ ", "https://eu.botapi.example/ring/v3/websockets/connect")] [InlineData("https://botapi.example:8443/ring/?route=test#fragment", "apac", "https://apac.botapi.example:8443/ring/v3/websockets/connect?route=test")] [InlineData("http://localhost:3978/", "amer", "http://amer.localhost:3978/v3/websockets/connect")] public async Task StartAsync_UsesGeoSubdomainsForCustomHosts(string baseUrl, string geo, string expectedUrl) From dbf238a3ce7f844b578740aa7ab562acab79e345 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 11:24:21 -0800 Subject: [PATCH 06/15] Show per-geo SignalR endpoints with normal sample logging Log negotiated SignalR endpoints at Information alongside transport lifecycle logs. Preserve Python-aligned connect routing and Debug diagnostics, and omit sensitive URL components from the new log. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- samples/SocketModeBot/README.md | 2 + src/Microsoft.Teams.Apps/README.md | 2 + .../SocketMode/GeoSocket.cs | 19 +++++++++- .../SocketMode/SignalRSocketConnection.cs | 5 ++- .../SocketMode/SocketModeConnection.cs | 6 +-- .../SocketMode/GeoSocketTests.cs | 2 +- .../SocketMode/SocketModeEndToEndTests.cs | 38 +++++++++++++++++-- 7 files changed, 64 insertions(+), 10 deletions(-) diff --git a/samples/SocketModeBot/README.md b/samples/SocketModeBot/README.md index 55a1d78a..f84ff09b 100644 --- a/samples/SocketModeBot/README.md +++ b/samples/SocketModeBot/README.md @@ -33,5 +33,7 @@ dotnet run --project samples/SocketModeBot/SocketModeBot.csproj Logging is configured in `appsettings.json`: Warning by default, with `Microsoft.Teams` and `SocketModeBot` at Information. +Each geo's returned SignalR endpoint is logged at Information alongside the aggregate status, with no logging +configuration changes needed. URL credentials, query strings, and fragments are omitted. The bot is ready when the log shows `Socket Mode ready across 3 geo(s).` (amer, emea, apac). Startup fails if any geo cannot connect within the startup timeout. diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index 86fb3b10..0214ae9c 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -250,6 +250,8 @@ service-negotiation rejections. Custom base hosts also use geo subdomains, preserving any existing base path; an empty geo uses the base URL without a geo subdomain. Each geo's resolved connect/negotiate URL is logged at Debug level during startup under `Microsoft.Teams.Apps.SocketMode.SocketModeTransport`. The SignalR URL returned by negotiation is used unchanged. +Each geo's returned SignalR endpoint is also logged at Information level on connection attempts, including +reconnects and token rotations. URL credentials, query strings, and fragments are omitted from this log. 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..7cd630d6 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs @@ -371,7 +371,24 @@ private async Task ConnectAsync(Generation? replacing, CancellationT envelope => DispatchAsync(generation, envelope), frame => HandleReady(generation, frame), (error, planned) => HandleClosed(generation, error, planned), - () => generation.TokenIssuedAt = _timeProvider.GetTimestamp()), + signalRUri => + { + generation.TokenIssuedAt = _timeProvider.GetTimestamp(); + if (_logger.IsEnabled(LogLevel.Information)) + { + UriBuilder endpoint = new(signalRUri) + { + UserName = string.Empty, + Password = string.Empty, + Query = string.Empty, + Fragment = string.Empty, + }; + _logger.LogInformation( + "Socket Mode geo {Geo} connecting to SignalR endpoint {SignalRUrl}.", + Geo, + endpoint.Uri.AbsoluteUri); + } + }), getReplacedConnectionId); bool owned; diff --git a/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs b/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs index e1ad2839..c9220a1b 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs @@ -143,14 +143,15 @@ public async Task StartAsync(CancellationToken cancellationToken) await _negotiator .NegotiateAsync(_negotiateUri, _getReplacedConnectionId, startSource.Token) .ConfigureAwait(false); - _handlers.OnNegotiated?.Invoke(); + Uri signalRUri = new(negotiateResponse.Url!, UriKind.Absolute); + _handlers.OnNegotiated?.Invoke(signalRUri); TokenLifetime = negotiateResponse.ExpiresIn > 0 ? TimeSpan.FromSeconds(negotiateResponse.ExpiresIn) : null; ISignalRClientConnection connection = _createSignalRClientConnection( - new Uri(negotiateResponse.Url!, UriKind.Absolute), + signalRUri, negotiateResponse.AccessToken!); if (!TryPublishConnection(connection)) { diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs index cc98e6ea..c3c76f7d 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs @@ -59,12 +59,12 @@ internal sealed class SocketConnectionHandlers /// Handles an incoming activity envelope. /// Handles the ready frame. /// Handles connection closure and indicates whether it was planned. - /// Runs when negotiate succeeds, which is when the token lifetime starts. + /// Receives the SignalR URL when negotiate succeeds, which is when the token lifetime starts. internal SocketConnectionHandlers( Func> onActivity, Action onReady, Action onClosed, - Action? onNegotiated = null) + Action? onNegotiated = null) { OnActivity = onActivity ?? throw new ArgumentNullException(nameof(onActivity)); OnReady = onReady ?? throw new ArgumentNullException(nameof(onReady)); @@ -90,5 +90,5 @@ internal SocketConnectionHandlers( /// /// Gets the callback that runs when negotiate succeeds. /// - internal Action? OnNegotiated { get; } + internal Action? OnNegotiated { get; } } diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs index 8ac4ff59..ce581293 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs @@ -1313,7 +1313,7 @@ public async Task StartAsync(CancellationToken cancellationToken) ReplacesConnectionId = GetReplacedConnectionId?.Invoke(); if (startError is null) { - Handlers.OnNegotiated?.Invoke(); + Handlers.OnNegotiated?.Invoke(new Uri("https://signalr.example.test/client")); } Started.TrySetResult(); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs index da6d12f8..7ac24b04 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs @@ -6,6 +6,7 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Apps.SocketMode; using Microsoft.Teams.Core.Schema; @@ -41,8 +42,21 @@ public async Task Transport_ComposesNegotiatorAndSignalRFactoryAcrossGeos() stack.Http.Requests.Select(request => request.AbsoluteUri).Order(StringComparer.Ordinal)); Assert.All(stack.Http.Authorizations, authorization => Assert.Equal("Bearer bot-token", authorization)); Assert.Equal( - ["https://signalr.test/amer", "https://signalr.test/apac", "https://signalr.test/emea"], + [ + "https://signalr.test/amer?access_token=secret#fragment", + "https://signalr.test/apac?access_token=secret#fragment", + "https://signalr.test/emea?access_token=secret#fragment", + ], clients.Select(client => client.Url.AbsoluteUri).Order(StringComparer.Ordinal)); + Assert.Equal( + [ + "Socket Mode geo amer connecting to SignalR endpoint https://signalr.test/amer.", + "Socket Mode geo apac connecting to SignalR endpoint https://signalr.test/apac.", + "Socket Mode geo emea connecting to SignalR endpoint https://signalr.test/emea.", + ], + stack.Logger.Information.Where(message => message.Contains("connecting to SignalR endpoint", StringComparison.Ordinal)) + .Order(StringComparer.Ordinal)); + Assert.DoesNotContain(stack.Logger.Information, message => message.Contains("secret", StringComparison.Ordinal)); FakeSignalRClient emea = clients.Single(client => client.Url.AbsolutePath == "/emea"); SocketReplyFrame? reply = await emea.ActivityAsync(new SocketActivityEnvelope @@ -120,7 +134,7 @@ internal EndToEnd(SocketModeTransportOptions? options = null) Dispatched.Enqueue(activity); return Task.FromResult(new SocketDispatchResult(200, null)); }, - NullLogger.Instance, + Logger, botKey: "bot-id"); } @@ -128,6 +142,24 @@ internal EndToEnd(SocketModeTransportOptions? options = null) internal FakeSignalRClientFactory SignalR { get; } = new(); internal ConcurrentQueue Dispatched { get; } = new(); internal SocketModeTransport Transport { get; } + internal InformationLogger Logger { get; } = new(); + } + + private sealed class InformationLogger : ILogger + { + internal ConcurrentQueue Information { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Information) + { + Information.Enqueue(formatter(state, exception)); + } + } } private sealed class NegotiateHandler : HttpMessageHandler @@ -169,7 +201,7 @@ protected override Task SendAsync( } string geo = uri.Host.Split('.')[0]; - string json = $$"""{"url":"https://signalr.test/{{geo}}","accessToken":"token-{{geo}}","expiresIn":3600}"""; + string json = $$"""{"url":"https://signalr.test/{{geo}}?access_token=secret#fragment","accessToken":"token-{{geo}}","expiresIn":3600}"""; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json"), From d4863a59b38b84083b60b1e4c2ee03f1934cbc82 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 11:28:56 -0800 Subject: [PATCH 07/15] Remove URL logging-specific test additions Keep connection and geo-routing coverage without asserting diagnostic output or adding logging-only fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../SocketMode/SocketModeEndToEndTests.cs | 38 ++----------------- .../SocketMode/SocketModeTransportTests.cs | 17 +-------- 2 files changed, 5 insertions(+), 50 deletions(-) diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs index 7ac24b04..da6d12f8 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs @@ -6,7 +6,6 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Teams.Apps.SocketMode; using Microsoft.Teams.Core.Schema; @@ -42,21 +41,8 @@ public async Task Transport_ComposesNegotiatorAndSignalRFactoryAcrossGeos() stack.Http.Requests.Select(request => request.AbsoluteUri).Order(StringComparer.Ordinal)); Assert.All(stack.Http.Authorizations, authorization => Assert.Equal("Bearer bot-token", authorization)); Assert.Equal( - [ - "https://signalr.test/amer?access_token=secret#fragment", - "https://signalr.test/apac?access_token=secret#fragment", - "https://signalr.test/emea?access_token=secret#fragment", - ], + ["https://signalr.test/amer", "https://signalr.test/apac", "https://signalr.test/emea"], clients.Select(client => client.Url.AbsoluteUri).Order(StringComparer.Ordinal)); - Assert.Equal( - [ - "Socket Mode geo amer connecting to SignalR endpoint https://signalr.test/amer.", - "Socket Mode geo apac connecting to SignalR endpoint https://signalr.test/apac.", - "Socket Mode geo emea connecting to SignalR endpoint https://signalr.test/emea.", - ], - stack.Logger.Information.Where(message => message.Contains("connecting to SignalR endpoint", StringComparison.Ordinal)) - .Order(StringComparer.Ordinal)); - Assert.DoesNotContain(stack.Logger.Information, message => message.Contains("secret", StringComparison.Ordinal)); FakeSignalRClient emea = clients.Single(client => client.Url.AbsolutePath == "/emea"); SocketReplyFrame? reply = await emea.ActivityAsync(new SocketActivityEnvelope @@ -134,7 +120,7 @@ internal EndToEnd(SocketModeTransportOptions? options = null) Dispatched.Enqueue(activity); return Task.FromResult(new SocketDispatchResult(200, null)); }, - Logger, + NullLogger.Instance, botKey: "bot-id"); } @@ -142,24 +128,6 @@ internal EndToEnd(SocketModeTransportOptions? options = null) internal FakeSignalRClientFactory SignalR { get; } = new(); internal ConcurrentQueue Dispatched { get; } = new(); internal SocketModeTransport Transport { get; } - internal InformationLogger Logger { get; } = new(); - } - - private sealed class InformationLogger : ILogger - { - internal ConcurrentQueue Information { get; } = new(); - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Information; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - if (logLevel == LogLevel.Information) - { - Information.Enqueue(formatter(state, exception)); - } - } } private sealed class NegotiateHandler : HttpMessageHandler @@ -201,7 +169,7 @@ protected override Task SendAsync( } string geo = uri.Host.Split('.')[0]; - string json = $$"""{"url":"https://signalr.test/{{geo}}?access_token=secret#fragment","accessToken":"token-{{geo}}","expiresIn":3600}"""; + string json = $$"""{"url":"https://signalr.test/{{geo}}","accessToken":"token-{{geo}}","expiresIn":3600}"""; return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json"), diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index f31d0ece..302729a3 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -19,8 +19,7 @@ public class SocketModeTransportTests [Fact] public async Task StartAsync_ConnectsEveryDefaultGeo() { - ErrorLogger logger = new(); - Harness harness = new() { Logger = logger }; + Harness harness = new(); Task start = harness.Transport.StartAsync(); FakeConnection[] connections = await harness.Factory.WaitForAsync(3); @@ -32,14 +31,6 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() "https://emea.botapi.skype.com/v3/websockets/connect", ], SortedUris(connections)); - Assert.Equal( - [ - "Socket Mode geo amer negotiate URL https://amer.botapi.skype.com/v3/websockets/connect.", - "Socket Mode geo apac negotiate URL https://apac.botapi.skype.com/v3/websockets/connect.", - "Socket Mode geo emea negotiate URL https://emea.botapi.skype.com/v3/websockets/connect.", - ], - logger.Records.Where(record => record.Level == LogLevel.Debug) - .Select(record => record.Message).Order(StringComparer.Ordinal)); Assert.False(start.IsCompleted); Assert.Equal(SocketModeStatus.Connecting, harness.Transport.Status); @@ -56,12 +47,11 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() [Fact] public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() { - ErrorLogger logger = new(); Harness harness = new(new SocketModeTransportOptions { NegotiateBaseUri = new Uri("https://botapi.example:3978/ring/"), Geos = ["", " /eu/ "], - }) { Logger = logger }; + }); Task start = harness.Transport.StartAsync(); FakeConnection[] connections = await harness.Factory.WaitForAsync(2); @@ -72,9 +62,6 @@ public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() "https://eu.botapi.example:3978/ring/v3/websockets/connect", ], SortedUris(connections)); - Assert.Contains(logger.Records, record => - record.Level == LogLevel.Debug - && record.Message == "Socket Mode geo default negotiate URL https://botapi.example:3978/ring/v3/websockets/connect."); foreach (FakeConnection connection in connections) { connection.Ready(); From 651202cf3bd378fd77b63e96187086a1510832e6 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 11:36:15 -0800 Subject: [PATCH 08/15] Log only connect negotiate URLs at Information level Remove returned SignalR endpoint logging and its callback plumbing. Keep Python-aligned negotiate URL diagnostics visible with the sample defaults and remove the returned-endpoint README notes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- samples/SocketModeBot/README.md | 2 -- src/Microsoft.Teams.Apps/README.md | 4 +--- .../SocketMode/GeoSocket.cs | 19 +------------------ .../SocketMode/SignalRSocketConnection.cs | 5 ++--- .../SocketMode/SocketModeConnection.cs | 6 +++--- .../SocketMode/SocketModeTransport.cs | 2 +- .../SocketMode/GeoSocketTests.cs | 2 +- 7 files changed, 9 insertions(+), 31 deletions(-) diff --git a/samples/SocketModeBot/README.md b/samples/SocketModeBot/README.md index f84ff09b..55a1d78a 100644 --- a/samples/SocketModeBot/README.md +++ b/samples/SocketModeBot/README.md @@ -33,7 +33,5 @@ dotnet run --project samples/SocketModeBot/SocketModeBot.csproj Logging is configured in `appsettings.json`: Warning by default, with `Microsoft.Teams` and `SocketModeBot` at Information. -Each geo's returned SignalR endpoint is logged at Information alongside the aggregate status, with no logging -configuration changes needed. URL credentials, query strings, and fragments are omitted. The bot is ready when the log shows `Socket Mode ready across 3 geo(s).` (amer, emea, apac). Startup fails if any geo cannot connect within the startup timeout. diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index 0214ae9c..7b7c44b2 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -248,10 +248,8 @@ service-negotiation rejections. `UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `NegotiateBaseUrl` and `Geos`. Custom base hosts also use geo subdomains, preserving any existing base path; an empty geo uses the base URL -without a geo subdomain. Each geo's resolved connect/negotiate URL is logged at Debug level during startup +without a geo subdomain. 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. -Each geo's returned SignalR endpoint is also logged at Information level on connection attempts, including -reconnects and token rotations. URL credentials, query strings, and fragments are omitted from this log. 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 7cd630d6..ce211c99 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs @@ -371,24 +371,7 @@ private async Task ConnectAsync(Generation? replacing, CancellationT envelope => DispatchAsync(generation, envelope), frame => HandleReady(generation, frame), (error, planned) => HandleClosed(generation, error, planned), - signalRUri => - { - generation.TokenIssuedAt = _timeProvider.GetTimestamp(); - if (_logger.IsEnabled(LogLevel.Information)) - { - UriBuilder endpoint = new(signalRUri) - { - UserName = string.Empty, - Password = string.Empty, - Query = string.Empty, - Fragment = string.Empty, - }; - _logger.LogInformation( - "Socket Mode geo {Geo} connecting to SignalR endpoint {SignalRUrl}.", - Geo, - endpoint.Uri.AbsoluteUri); - } - }), + () => generation.TokenIssuedAt = _timeProvider.GetTimestamp()), getReplacedConnectionId); bool owned; diff --git a/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs b/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs index c9220a1b..e1ad2839 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SignalRSocketConnection.cs @@ -143,15 +143,14 @@ public async Task StartAsync(CancellationToken cancellationToken) await _negotiator .NegotiateAsync(_negotiateUri, _getReplacedConnectionId, startSource.Token) .ConfigureAwait(false); - Uri signalRUri = new(negotiateResponse.Url!, UriKind.Absolute); - _handlers.OnNegotiated?.Invoke(signalRUri); + _handlers.OnNegotiated?.Invoke(); TokenLifetime = negotiateResponse.ExpiresIn > 0 ? TimeSpan.FromSeconds(negotiateResponse.ExpiresIn) : null; ISignalRClientConnection connection = _createSignalRClientConnection( - signalRUri, + new Uri(negotiateResponse.Url!, UriKind.Absolute), negotiateResponse.AccessToken!); if (!TryPublishConnection(connection)) { diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs index c3c76f7d..cc98e6ea 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeConnection.cs @@ -59,12 +59,12 @@ internal sealed class SocketConnectionHandlers /// Handles an incoming activity envelope. /// Handles the ready frame. /// Handles connection closure and indicates whether it was planned. - /// Receives the SignalR URL when negotiate succeeds, which is when the token lifetime starts. + /// Runs when negotiate succeeds, which is when the token lifetime starts. internal SocketConnectionHandlers( Func> onActivity, Action onReady, Action onClosed, - Action? onNegotiated = null) + Action? onNegotiated = null) { OnActivity = onActivity ?? throw new ArgumentNullException(nameof(onActivity)); OnReady = onReady ?? throw new ArgumentNullException(nameof(onReady)); @@ -90,5 +90,5 @@ internal SocketConnectionHandlers( /// /// Gets the callback that runs when negotiate succeeds. /// - internal Action? OnNegotiated { get; } + internal Action? OnNegotiated { get; } } diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs index 96d69099..5f44d7ce 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs @@ -243,7 +243,7 @@ internal async Task StartAsync(CancellationToken cancellationToken = default) foreach (var geo in _geos) { - _logger.LogDebug( + _logger.LogInformation( "Socket Mode geo {Geo} negotiate URL {NegotiateUrl}.", geo.Geo.Length > 0 ? geo.Geo : "default", geo.NegotiateUri); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs index ce581293..8ac4ff59 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs @@ -1313,7 +1313,7 @@ public async Task StartAsync(CancellationToken cancellationToken) ReplacesConnectionId = GetReplacedConnectionId?.Invoke(); if (startError is null) { - Handlers.OnNegotiated?.Invoke(new Uri("https://signalr.example.test/client")); + Handlers.OnNegotiated?.Invoke(); } Started.TrySetResult(); From 40016d6f6460f15e636682ce8ce0f1f0fa6a3313 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 11:48:36 -0800 Subject: [PATCH 09/15] Remove misleading localhost geo routing test case Do not imply support for geo-prefixed HTTP localhost endpoints that the real negotiator rejects. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../SocketMode/SocketModeTransportTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index 302729a3..4979fa03 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -73,7 +73,6 @@ public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() [Theory] [InlineData("https://botapi.example", "amer", "https://amer.botapi.example/v3/websockets/connect")] [InlineData("https://botapi.example:8443/ring/?route=test#fragment", "apac", "https://apac.botapi.example:8443/ring/v3/websockets/connect?route=test")] - [InlineData("http://localhost:3978/", "amer", "http://amer.localhost:3978/v3/websockets/connect")] public async Task StartAsync_UsesGeoSubdomainsForCustomHosts(string baseUrl, string geo, string expectedUrl) { Harness harness = new(new SocketModeTransportOptions From f15b85db2329de56198816d2c6cd2cbdeab62c81 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 11:50:45 -0800 Subject: [PATCH 10/15] Fix empty-geo connect URL path construction Use UriBuilder for every geo, keeping only the host prefix conditional. Preserve query parameters and discard fragments when appending the connect path; cover empty geos with exact URL regression cases. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../SocketMode/SocketModeTransport.cs | 8 +------- .../SocketMode/SocketModeTransportTests.cs | 7 +++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs index 5f44d7ce..b70605cb 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs @@ -527,15 +527,9 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ throw new ArgumentException($"Socket Mode geo '{geo}' is listed more than once.", nameof(geos)); } - if (segment.Length == 0) - { - resolved.Add((segment, new Uri(baseUri.AbsoluteUri.TrimEnd('/') + SocketModeProtocol.NegotiatePath, UriKind.Absolute))); - continue; - } - UriBuilder builder = new(baseUri) { - Host = $"{segment}.{baseUri.Host}", + Host = segment.Length > 0 ? $"{segment}.{baseUri.Host}" : baseUri.Host, Path = baseUri.AbsolutePath.TrimEnd('/') + SocketModeProtocol.NegotiatePath, Fragment = string.Empty, }; diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index 4979fa03..879f1da6 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -73,7 +73,10 @@ public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() [Theory] [InlineData("https://botapi.example", "amer", "https://amer.botapi.example/v3/websockets/connect")] [InlineData("https://botapi.example:8443/ring/?route=test#fragment", "apac", "https://apac.botapi.example:8443/ring/v3/websockets/connect?route=test")] - public async Task StartAsync_UsesGeoSubdomainsForCustomHosts(string baseUrl, string geo, string expectedUrl) + [InlineData("https://botapi.example/ring/?route=test", "", "https://botapi.example/ring/v3/websockets/connect?route=test")] + [InlineData("https://botapi.example/ring/#fragment", "", "https://botapi.example/ring/v3/websockets/connect")] + [InlineData("https://botapi.example:8443/ring/?route=test#fragment", "", "https://botapi.example:8443/ring/v3/websockets/connect?route=test")] + public async Task StartAsync_ResolvesCustomBaseUrls(string baseUrl, string geo, string expectedUrl) { Harness harness = new(new SocketModeTransportOptions { @@ -83,7 +86,7 @@ public async Task StartAsync_UsesGeoSubdomainsForCustomHosts(string baseUrl, str Task start = harness.Transport.StartAsync(); FakeConnection connection = Assert.Single(await harness.Factory.WaitForAsync(1)); - Assert.Equal(new Uri(expectedUrl), connection.NegotiateUri); + Assert.Equal(expectedUrl, connection.NegotiateUri.AbsoluteUri); connection.Ready(); await start; await harness.Transport.DisposeAsync(); From 56b73fbfa7fec2544dbca8cdfd589fdc08b9a6d8 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 12:46:24 -0800 Subject: [PATCH 11/15] Use null-safe geo length check in connection logging Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs index b70605cb..3b7c4b12 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs @@ -245,7 +245,7 @@ internal async Task StartAsync(CancellationToken cancellationToken = default) { _logger.LogInformation( "Socket Mode geo {Geo} negotiate URL {NegotiateUrl}.", - geo.Geo.Length > 0 ? geo.Geo : "default", + geo.Geo?.Length > 0 ? geo.Geo : "default", geo.NegotiateUri); } From 8af001c60e87c02a58df04d965c70202662e9fc6 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 14:20:06 -0800 Subject: [PATCH 12/15] Require geo-specific Socket Mode traffic manager endpoints Use smbatrafficmanager.net for Socket Mode connect requests, remove configurable negotiate base URLs, and require at least one geo. Preserve unrelated authentication scopes and CompatBot URLs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- samples/SocketModeBot/README.md | 4 +- src/Microsoft.Teams.Apps/README.md | 8 +-- .../SocketMode/GeoSocket.cs | 2 +- .../SocketMode/SocketModeOptions.cs | 12 +--- .../SocketMode/SocketModeProtocol.cs | 4 +- .../SocketMode/SocketModeTransport.cs | 29 ++++----- .../SocketMode/GeoSocketTests.cs | 2 +- .../SignalRSocketConnectionTests.cs | 2 +- .../SocketMode/SocketModeEndToEndTests.cs | 6 +- .../SocketMode/SocketModeHostingTests.cs | 8 ++- .../SocketMode/SocketModeNegotiatorTests.cs | 10 +-- .../SocketMode/SocketModeObserverTests.cs | 7 +-- .../SocketMode/SocketModeOptionsTests.cs | 9 +-- .../SocketMode/SocketModeTransportTests.cs | 63 ++++++------------- 14 files changed, 66 insertions(+), 100 deletions(-) diff --git a/samples/SocketModeBot/README.md b/samples/SocketModeBot/README.md index 55a1d78a..8f55e9d1 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 production connect endpoints (`https://{geo}.botapi.skype.com/v3/websockets/connect`). -- 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 using the production connect endpoints (`https://{geo}.smbatrafficmanager.net/v3/websockets/connect`). +- One connection per geo (`amer`, `emea`, `apac`) by default for inbound coverage across regions. Configure `Geos = [...]` in the `UseSocketMode(socket => ...)` callback with at least one geo. - `$(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 7b7c44b2..6731d2fc 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -215,7 +215,7 @@ host.Run(); - **One connection per geo** — The bot connects to `amer`, `emea`, and `apac` by default. Startup waits until every geo is ready and fails if any geo cannot connect within 30 seconds. Dropped connections reconnect automatically, and connection tokens are rotated before they expire. - Production connect requests use `https://{geo}.botapi.skype.com/v3/websockets/connect`. + Production connect requests use `https://{geo}.smbatrafficmanager.net/v3/websockets/connect`. - **No web server** — `UseSocketMode` is the only switch; `UseTeamsBotApplication()` is the same call for both transports. With Socket Mode enabled it throws on a `WebApplication`, and a host that includes a web server fails to start. Tabs, OAuth callbacks, health endpoints, and other HTTP routes are unavailable. @@ -246,9 +246,9 @@ 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`. -Custom base hosts also use geo subdomains, preserving any existing base path; an empty geo uses the base URL -without a geo subdomain. Each geo's resolved connect/negotiate URL is logged at Information level during startup +`UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `Geos`. +At least one geo is required; null, empty, whitespace-only, and duplicate geo entries are rejected. +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. 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/SocketModeOptions.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs index 7ee45088..1aba02a1 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs @@ -20,15 +20,8 @@ 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. - /// Connections use the geo as a subdomain, including for custom hosts, and preserve the base URL's path. - /// - 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 - /// subdomain. Defaults to amer, emea, and apac. + /// Gets or sets the geos to connect, one connection each. At least one geo is required. + /// Defaults to amer, emea, and apac. /// Override this to connect to a different set of geos. /// public IReadOnlyList Geos { get; set; } = SocketModeProtocol.DefaultGeos; @@ -40,7 +33,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 c13e388b..a8ca19f5 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 = "smbatrafficmanager.net"; /// /// The default geographies for Socket Mode connections. diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs index 3b7c4b12..985a78bb 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 without a geo subdomain. + /// Gets the geos to connect. At least one geo is required. /// 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 empty, 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); } /// @@ -245,7 +239,7 @@ internal async Task StartAsync(CancellationToken cancellationToken = default) { _logger.LogInformation( "Socket Mode geo {Geo} negotiate URL {NegotiateUrl}.", - geo.Geo?.Length > 0 ? geo.Geo : "default", + geo.Geo, geo.NegotiateUri); } @@ -502,12 +496,12 @@ 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) { throw new ArgumentException( - "Socket Mode requires at least one geo. Use an empty string to connect without a geo subdomain.", + "Socket Mode requires at least one geo.", nameof(geos)); } @@ -522,16 +516,19 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ } string segment = geo.Trim().Trim('/'); + if (string.IsNullOrWhiteSpace(segment)) + { + throw new ArgumentException("Socket Mode geos cannot be empty or whitespace.", nameof(geos)); + } + if (!seen.Add(segment)) { throw new ArgumentException($"Socket Mode geo '{geo}' is listed more than once.", nameof(geos)); } - UriBuilder builder = new(baseUri) + UriBuilder builder = new(Uri.UriSchemeHttps, $"{segment}.{SocketModeProtocol.NegotiateHost}") { - Host = segment.Length > 0 ? $"{segment}.{baseUri.Host}" : baseUri.Host, - Path = baseUri.AbsolutePath.TrimEnd('/') + SocketModeProtocol.NegotiatePath, - Fragment = string.Empty, + Path = SocketModeProtocol.NegotiatePath, }; resolved.Add((segment, builder.Uri)); } diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs index 8ac4ff59..2930088c 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.smbatrafficmanager.net/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 22adb4b8..8bad2631 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs @@ -9,7 +9,7 @@ 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.smbatrafficmanager.net/v3/websockets/connect"); private static readonly Uri SignalRUri = new("https://signalr.example.test/client?hub=teams"); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs index da6d12f8..b817ce80 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://amer.botapi.skype.com/v3/websockets/connect", - "https://apac.botapi.skype.com/v3/websockets/connect", - "https://emea.botapi.skype.com/v3/websockets/connect", + "https://amer.smbatrafficmanager.net/v3/websockets/connect", + "https://apac.smbatrafficmanager.net/v3/websockets/connect", + "https://emea.smbatrafficmanager.net/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)); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs index fe05d6aa..4b45ed26 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs @@ -94,7 +94,11 @@ public void WithSocketMode_AcceptsPublicIssuerWithTrailingSlash() public static TheoryData> InvalidOptions => new() { + o => o.Geos = null!, o => o.Geos = [], + o => o.Geos = [""], + o => o.Geos = [" "], + o => o.Geos = ["amer", ""], o => o.Geos = ["amer", null!], o => o.Geos = ["amer", " AMER/"], }; @@ -135,7 +139,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.smbatrafficmanager.net/v3/websockets/connect")); Assert.Equal("signalr-token", response.AccessToken); Assert.Equal(["https://api.botframework.com/.default"], scopes); @@ -165,7 +169,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://amer.botapi.skype.com/v3/websockets/connect"), socket.NegotiateUrl); + Assert.Equal(new Uri("https://amer.smbatrafficmanager.net/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..d3f936bf 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeNegotiatorTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeNegotiatorTests.cs @@ -11,10 +11,10 @@ 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.smbatrafficmanager.net/v3/websockets/connect"); [Theory] - [InlineData("https://botapi.skype.com/v3/websockets/connect")] + [InlineData("https://amer.smbatrafficmanager.net/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")] @@ -30,11 +30,11 @@ public async Task NegotiateAsync_AllowsSecureAndLoopbackUris(string uri) } [Theory] - [InlineData("http://botapi.skype.com/v3/websockets/connect")] + [InlineData("http://amer.smbatrafficmanager.net/v3/websockets/connect")] [InlineData("http://127.0.0.2/v3/websockets/connect")] - [InlineData("wss://botapi.skype.com/v3/websockets/connect")] + [InlineData("wss://amer.smbatrafficmanager.net/v3/websockets/connect")] [InlineData("ws://localhost:5000/v3/websockets/connect")] - [InlineData("ftp://botapi.skype.com/v3/websockets/connect")] + [InlineData("ftp://amer.smbatrafficmanager.net/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 acbd6714..003e53a2 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://eu.canary.botapi.skype.com/v3/websockets/connect"), observer.NegotiateUrl); - Assert.Equal(["eu"], observer.GeoList); + Assert.Equal(new Uri("https://emea.smbatrafficmanager.net/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 879f1da6..63e2bcf7 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -26,9 +26,9 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() Assert.Equal( [ - "https://amer.botapi.skype.com/v3/websockets/connect", - "https://apac.botapi.skype.com/v3/websockets/connect", - "https://emea.botapi.skype.com/v3/websockets/connect", + "https://amer.smbatrafficmanager.net/v3/websockets/connect", + "https://apac.smbatrafficmanager.net/v3/websockets/connect", + "https://emea.smbatrafficmanager.net/v3/websockets/connect", ], SortedUris(connections)); Assert.False(start.IsCompleted); @@ -44,60 +44,40 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() Assert.All(harness.Transport.GeoStatuses.Values, status => Assert.Equal(SocketModeStatus.Ready, status)); } - [Fact] - public async Task StartAsync_BuildsGeoUrisFromCustomBaseAndEmptyGeo() - { - Harness harness = new(new SocketModeTransportOptions - { - NegotiateBaseUri = new Uri("https://botapi.example:3978/ring/"), - Geos = ["", " /eu/ "], - }); - - Task start = harness.Transport.StartAsync(); - FakeConnection[] connections = await harness.Factory.WaitForAsync(2); - - Assert.Equal( - [ - "https://botapi.example:3978/ring/v3/websockets/connect", - "https://eu.botapi.example:3978/ring/v3/websockets/connect", - ], - SortedUris(connections)); - foreach (FakeConnection connection in connections) - { - connection.Ready(); - } - - await start; - } - [Theory] - [InlineData("https://botapi.example", "amer", "https://amer.botapi.example/v3/websockets/connect")] - [InlineData("https://botapi.example:8443/ring/?route=test#fragment", "apac", "https://apac.botapi.example:8443/ring/v3/websockets/connect?route=test")] - [InlineData("https://botapi.example/ring/?route=test", "", "https://botapi.example/ring/v3/websockets/connect?route=test")] - [InlineData("https://botapi.example/ring/#fragment", "", "https://botapi.example/ring/v3/websockets/connect")] - [InlineData("https://botapi.example:8443/ring/?route=test#fragment", "", "https://botapi.example:8443/ring/v3/websockets/connect?route=test")] - public async Task StartAsync_ResolvesCustomBaseUrls(string baseUrl, string geo, string expectedUrl) + [InlineData("amer", "amer")] + [InlineData("emea", "emea")] + [InlineData("apac", "apac")] + [InlineData(" /emea/ ", "emea")] + public async Task StartAsync_ConnectsOneConfiguredGeo(string geo, string expectedGeo) { Harness harness = new(new SocketModeTransportOptions { - NegotiateBaseUri = new Uri(baseUrl), Geos = [geo], }); Task start = harness.Transport.StartAsync(); FakeConnection connection = Assert.Single(await harness.Factory.WaitForAsync(1)); - Assert.Equal(expectedUrl, connection.NegotiateUri.AbsoluteUri); + Assert.Equal($"https://{expectedGeo}.smbatrafficmanager.net/v3/websockets/connect", connection.NegotiateUri.AbsoluteUri); + Assert.Equal([expectedGeo], harness.Transport.GeoList); connection.Ready(); await start; await harness.Transport.DisposeAsync(); } [Theory] + [InlineData(null)] [InlineData(new object[] { new string[0] })] + [InlineData(new object[] { new string[] { null! } })] + [InlineData(new object[] { new[] { "" } })] + [InlineData(new object[] { new[] { " " } })] + [InlineData(new object[] { new[] { "/" } })] + [InlineData(new object[] { new[] { " / / " } })] + [InlineData(new object[] { new[] { "amer", "" } })] [InlineData(new object[] { new[] { "amer", "AMER" } })] - public void Constructor_RejectsInvalidGeos(string[] geos) + public void Constructor_RejectsInvalidGeos(string[]? geos) { - Assert.Throws(() => new Harness(new SocketModeTransportOptions { Geos = geos }).Transport); + Assert.Throws(() => new Harness(new SocketModeTransportOptions { Geos = geos! }).Transport); } [Fact] @@ -913,10 +893,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 = negotiateUri.Host.EndsWith(".botapi.skype.com", StringComparison.OrdinalIgnoreCase) - ? negotiateUri.Host.Split('.')[0] - : segments.Length > 3 ? segments[0] : string.Empty; + string geo = negotiateUri.Host.Split('.')[0]; FakeConnection connection = new( negotiateUri, geo, From b275849a673b071b049f4bdf90c2acb4f35a7cbe Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 14:23:47 -0800 Subject: [PATCH 13/15] Correct Socket Mode host to smba.trafficmanager.net Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- samples/SocketModeBot/README.md | 2 +- src/Microsoft.Teams.Apps/README.md | 2 +- .../SocketMode/SocketModeProtocol.cs | 2 +- .../SocketMode/GeoSocketTests.cs | 2 +- .../SocketMode/SignalRSocketConnectionTests.cs | 2 +- .../SocketMode/SocketModeEndToEndTests.cs | 6 +++--- .../SocketMode/SocketModeHostingTests.cs | 4 ++-- .../SocketMode/SocketModeNegotiatorTests.cs | 10 +++++----- .../SocketMode/SocketModeObserverTests.cs | 2 +- .../SocketMode/SocketModeTransportTests.cs | 8 ++++---- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/samples/SocketModeBot/README.md b/samples/SocketModeBot/README.md index 8f55e9d1..4ee8f557 100644 --- a/samples/SocketModeBot/README.md +++ b/samples/SocketModeBot/README.md @@ -9,7 +9,7 @@ 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 production connect endpoints (`https://{geo}.smbatrafficmanager.net/v3/websockets/connect`). +- `UseSocketMode()` on `AddTeamsBotApplication` to receive activities over Socket Mode using the production connect endpoints (`https://{geo}.smba.trafficmanager.net/v3/websockets/connect`). - One connection per geo (`amer`, `emea`, `apac`) by default for inbound coverage across regions. Configure `Geos = [...]` in the `UseSocketMode(socket => ...)` callback with at least one geo. - `$(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. diff --git a/src/Microsoft.Teams.Apps/README.md b/src/Microsoft.Teams.Apps/README.md index 6731d2fc..76971ca6 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -215,7 +215,7 @@ host.Run(); - **One connection per geo** — The bot connects to `amer`, `emea`, and `apac` by default. Startup waits until every geo is ready and fails if any geo cannot connect within 30 seconds. Dropped connections reconnect automatically, and connection tokens are rotated before they expire. - Production connect requests use `https://{geo}.smbatrafficmanager.net/v3/websockets/connect`. + Production connect requests use `https://{geo}.smba.trafficmanager.net/v3/websockets/connect`. - **No web server** — `UseSocketMode` is the only switch; `UseTeamsBotApplication()` is the same call for both transports. With Socket Mode enabled it throws on a `WebApplication`, and a host that includes a web server fails to start. Tabs, OAuth callbacks, health endpoints, and other HTTP routes are unavailable. diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs index a8ca19f5..1cdeb57b 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs @@ -16,7 +16,7 @@ internal static class SocketModeProtocol /// /// The service hostname used for Socket Mode negotiation. /// - internal const string NegotiateHost = "smbatrafficmanager.net"; + internal const string NegotiateHost = "smba.trafficmanager.net"; /// /// The default geographies for Socket Mode connections. diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/GeoSocketTests.cs index 2930088c..a451754c 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://amer.smbatrafficmanager.net/v3/websockets/connect"); + private static readonly Uri NegotiateUri = new("https://amer.smba.trafficmanager.net/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 8bad2631..92025758 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs @@ -9,7 +9,7 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode; public class SignalRSocketConnectionTests { private static readonly Uri NegotiateUri = - new("https://amer.smbatrafficmanager.net/v3/websockets/connect"); + new("https://amer.smba.trafficmanager.net/v3/websockets/connect"); private static readonly Uri SignalRUri = new("https://signalr.example.test/client?hub=teams"); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs index b817ce80..75b5c3f5 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://amer.smbatrafficmanager.net/v3/websockets/connect", - "https://apac.smbatrafficmanager.net/v3/websockets/connect", - "https://emea.smbatrafficmanager.net/v3/websockets/connect", + "https://amer.smba.trafficmanager.net/v3/websockets/connect", + "https://apac.smba.trafficmanager.net/v3/websockets/connect", + "https://emea.smba.trafficmanager.net/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)); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs index 4b45ed26..71df2645 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs @@ -139,7 +139,7 @@ public async Task Negotiator_UsesTheBotTokenForTheBotFrameworkScopeOnAPlainClien using ServiceProvider provider = services.BuildServiceProvider(); SocketModeNegotiateResponse response = await provider.GetRequiredService() - .NegotiateAsync(new Uri("https://amer.smbatrafficmanager.net/v3/websockets/connect")); + .NegotiateAsync(new Uri("https://amer.smba.trafficmanager.net/v3/websockets/connect")); Assert.Equal("signalr-token", response.AccessToken); Assert.Equal(["https://api.botframework.com/.default"], scopes); @@ -169,7 +169,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://amer.smbatrafficmanager.net/v3/websockets/connect"), socket.NegotiateUrl); + Assert.Equal(new Uri("https://amer.smba.trafficmanager.net/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 d3f936bf..2d48bc11 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeNegotiatorTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeNegotiatorTests.cs @@ -11,10 +11,10 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode; public class SocketModeNegotiatorTests { private const string BotToken = "bot-token"; - private static readonly Uri NegotiateUri = new("https://amer.smbatrafficmanager.net/v3/websockets/connect"); + private static readonly Uri NegotiateUri = new("https://amer.smba.trafficmanager.net/v3/websockets/connect"); [Theory] - [InlineData("https://amer.smbatrafficmanager.net/v3/websockets/connect")] + [InlineData("https://amer.smba.trafficmanager.net/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")] @@ -30,11 +30,11 @@ public async Task NegotiateAsync_AllowsSecureAndLoopbackUris(string uri) } [Theory] - [InlineData("http://amer.smbatrafficmanager.net/v3/websockets/connect")] + [InlineData("http://amer.smba.trafficmanager.net/v3/websockets/connect")] [InlineData("http://127.0.0.2/v3/websockets/connect")] - [InlineData("wss://amer.smbatrafficmanager.net/v3/websockets/connect")] + [InlineData("wss://amer.smba.trafficmanager.net/v3/websockets/connect")] [InlineData("ws://localhost:5000/v3/websockets/connect")] - [InlineData("ftp://amer.smbatrafficmanager.net/v3/websockets/connect")] + [InlineData("ftp://amer.smba.trafficmanager.net/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 003e53a2..826fbb0c 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs @@ -31,7 +31,7 @@ public async Task Diagnostics_AreLazyAndDoNotConnect() observer.Events.Ready += (_, _) => { }; Assert.Equal(0, created); Assert.Equal(SocketModeStatus.Idle, observer.Status); - Assert.Equal(new Uri("https://emea.smbatrafficmanager.net/v3/websockets/connect"), observer.NegotiateUrl); + Assert.Equal(new Uri("https://emea.smba.trafficmanager.net/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/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index 63e2bcf7..b468c2a4 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs @@ -26,9 +26,9 @@ public async Task StartAsync_ConnectsEveryDefaultGeo() Assert.Equal( [ - "https://amer.smbatrafficmanager.net/v3/websockets/connect", - "https://apac.smbatrafficmanager.net/v3/websockets/connect", - "https://emea.smbatrafficmanager.net/v3/websockets/connect", + "https://amer.smba.trafficmanager.net/v3/websockets/connect", + "https://apac.smba.trafficmanager.net/v3/websockets/connect", + "https://emea.smba.trafficmanager.net/v3/websockets/connect", ], SortedUris(connections)); Assert.False(start.IsCompleted); @@ -58,7 +58,7 @@ public async Task StartAsync_ConnectsOneConfiguredGeo(string geo, string expecte Task start = harness.Transport.StartAsync(); FakeConnection connection = Assert.Single(await harness.Factory.WaitForAsync(1)); - Assert.Equal($"https://{expectedGeo}.smbatrafficmanager.net/v3/websockets/connect", connection.NegotiateUri.AbsoluteUri); + Assert.Equal($"https://{expectedGeo}.smba.trafficmanager.net/v3/websockets/connect", connection.NegotiateUri.AbsoluteUri); Assert.Equal([expectedGeo], harness.Transport.GeoList); connection.Ready(); await start; From 7e9df0ae9773bd7ab023f645ef3f8de7645cf452 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 15:39:54 -0800 Subject: [PATCH 14/15] Align Socket Mode geo configuration and restore connect host Restore geo-prefixed botapi.skype.com endpoints, default empty geo lists, and check exact empty strings and case-sensitive duplicates. Escape geo URL components, simplify tests, and remove unnecessary endpoint documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- samples/SocketModeBot/README.md | 4 +- src/Microsoft.Teams.Apps/README.md | 4 +- .../SocketMode/SocketModeOptions.cs | 4 +- .../SocketMode/SocketModeProtocol.cs | 2 +- .../SocketMode/SocketModeTransport.cs | 41 +++++++------- .../SocketMode/GeoSocketTests.cs | 2 +- .../SignalRSocketConnectionTests.cs | 2 +- .../SocketMode/SocketModeEndToEndTests.cs | 6 +-- .../SocketMode/SocketModeHostingTests.cs | 8 ++- .../SocketMode/SocketModeNegotiatorTests.cs | 26 ++------- .../SocketMode/SocketModeObserverTests.cs | 4 +- .../SocketMode/SocketModeTransportTests.cs | 54 +++++++++++-------- 12 files changed, 73 insertions(+), 84 deletions(-) diff --git a/samples/SocketModeBot/README.md b/samples/SocketModeBot/README.md index 4ee8f557..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 production connect endpoints (`https://{geo}.smba.trafficmanager.net/v3/websockets/connect`). -- One connection per geo (`amer`, `emea`, `apac`) by default for inbound coverage across regions. Configure `Geos = [...]` in the `UseSocketMode(socket => ...)` callback with at least one geo. +- `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 76971ca6..48a83e7e 100644 --- a/src/Microsoft.Teams.Apps/README.md +++ b/src/Microsoft.Teams.Apps/README.md @@ -215,7 +215,6 @@ host.Run(); - **One connection per geo** — The bot connects to `amer`, `emea`, and `apac` by default. Startup waits until every geo is ready and fails if any geo cannot connect within 30 seconds. Dropped connections reconnect automatically, and connection tokens are rotated before they expire. - Production connect requests use `https://{geo}.smba.trafficmanager.net/v3/websockets/connect`. - **No web server** — `UseSocketMode` is the only switch; `UseTeamsBotApplication()` is the same call for both transports. With Socket Mode enabled it throws on a `WebApplication`, and a host that includes a web server fails to start. Tabs, OAuth callbacks, health endpoints, and other HTTP routes are unavailable. @@ -247,7 +246,8 @@ and `.IsAuthError` for HTTP 401/403. Downstream SignalR negotiation errors are n service-negotiation rejections. `UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `Geos`. -At least one geo is required; null, empty, whitespace-only, and duplicate geo entries are rejected. +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. diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs index 1aba02a1..984b2224 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs @@ -20,8 +20,8 @@ namespace Microsoft.Teams.Apps.SocketMode; public sealed class SocketModeOptions { /// - /// Gets or sets the geos to connect, one connection each. At least one geo is required. - /// Defaults to amer, emea, and apac. + /// 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; diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs index 1cdeb57b..92b25c99 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs @@ -16,7 +16,7 @@ internal static class SocketModeProtocol /// /// The service hostname used for Socket Mode negotiation. /// - internal const string NegotiateHost = "smba.trafficmanager.net"; + internal const string NegotiateHost = "botapi.skype.com"; /// /// The default geographies for Socket Mode connections. diff --git a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs index 985a78bb..bcf0b5d9 100644 --- a/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs +++ b/src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs @@ -48,7 +48,7 @@ public enum SocketModeStatus internal sealed class SocketModeTransportOptions { /// - /// Gets the geos to connect. At least one geo is required. + /// Gets the geos to connect. An empty list uses the default geos. /// internal IReadOnlyList Geos { get; init; } = SocketModeProtocol.DefaultGeos; @@ -112,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 geo list is null or empty, or contains null, empty, or duplicate entries. + /// 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, @@ -498,14 +498,26 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ 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 requires at least one geo.", - nameof(geos)); + throw new ArgumentException("Socket Mode geos cannot be null.", nameof(geos)); + } + + if (geos.Count == 0) + { + 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)); } - HashSet seen = new(StringComparer.OrdinalIgnoreCase); List<(string Geo, Uri NegotiateUri)> resolved = []; foreach (string geo in geos) @@ -515,22 +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 (string.IsNullOrWhiteSpace(segment)) - { - throw new ArgumentException("Socket Mode geos cannot be empty or whitespace.", nameof(geos)); - } - - if (!seen.Add(segment)) - { - throw new ArgumentException($"Socket Mode geo '{geo}' is listed more than once.", nameof(geos)); - } - - UriBuilder builder = new(Uri.UriSchemeHttps, $"{segment}.{SocketModeProtocol.NegotiateHost}") + UriBuilder builder = new(Uri.UriSchemeHttps, $"{Uri.EscapeDataString(geo)}.{SocketModeProtocol.NegotiateHost}") { Path = SocketModeProtocol.NegotiatePath, }; - resolved.Add((segment, builder.Uri)); + 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 a451754c..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://amer.smba.trafficmanager.net/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 92025758..1ab7eec1 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SignalRSocketConnectionTests.cs @@ -9,7 +9,7 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode; public class SignalRSocketConnectionTests { private static readonly Uri NegotiateUri = - new("https://amer.smba.trafficmanager.net/v3/websockets/connect"); + new("https://amer.botapi.skype.com/v3/websockets/connect"); private static readonly Uri SignalRUri = new("https://signalr.example.test/client?hub=teams"); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeEndToEndTests.cs index 75b5c3f5..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://amer.smba.trafficmanager.net/v3/websockets/connect", - "https://apac.smba.trafficmanager.net/v3/websockets/connect", - "https://emea.smba.trafficmanager.net/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)); diff --git a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs index 71df2645..8168ae5b 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeHostingTests.cs @@ -95,12 +95,10 @@ public void WithSocketMode_AcceptsPublicIssuerWithTrailingSlash() public static TheoryData> InvalidOptions => new() { o => o.Geos = null!, - o => o.Geos = [], o => o.Geos = [""], - o => o.Geos = [" "], o => o.Geos = ["amer", ""], o => o.Geos = ["amer", null!], - o => o.Geos = ["amer", " AMER/"], + o => o.Geos = ["amer", "amer"], }; [Theory] @@ -139,7 +137,7 @@ public async Task Negotiator_UsesTheBotTokenForTheBotFrameworkScopeOnAPlainClien using ServiceProvider provider = services.BuildServiceProvider(); SocketModeNegotiateResponse response = await provider.GetRequiredService() - .NegotiateAsync(new Uri("https://amer.smba.trafficmanager.net/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); @@ -169,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://amer.smba.trafficmanager.net/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 2d48bc11..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://amer.smba.trafficmanager.net/v3/websockets/connect"); + private static readonly Uri NegotiateUri = new("https://amer.botapi.skype.com/v3/websockets/connect"); [Theory] - [InlineData("https://amer.smba.trafficmanager.net/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://amer.smba.trafficmanager.net/v3/websockets/connect")] - [InlineData("http://127.0.0.2/v3/websockets/connect")] - [InlineData("wss://amer.smba.trafficmanager.net/v3/websockets/connect")] - [InlineData("ws://localhost:5000/v3/websockets/connect")] - [InlineData("ftp://amer.smba.trafficmanager.net/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 826fbb0c..4549517e 100644 --- a/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs +++ b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeObserverTests.cs @@ -16,7 +16,7 @@ public async Task Diagnostics_AreLazyAndDoNotConnect() await using SocketModeTransport transport = new( new SocketModeTransportOptions { - Geos = [" /emea/ "], + Geos = ["emea"], }, new UnusedConnectionFactory(), _ => Task.FromResult(new SocketDispatchResult(200)), @@ -31,7 +31,7 @@ public async Task Diagnostics_AreLazyAndDoNotConnect() observer.Events.Ready += (_, _) => { }; Assert.Equal(0, created); Assert.Equal(SocketModeStatus.Idle, observer.Status); - Assert.Equal(new Uri("https://emea.smba.trafficmanager.net/v3/websockets/connect"), observer.NegotiateUrl); + 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/SocketModeTransportTests.cs b/test/Microsoft.Teams.Apps.UnitTests/SocketMode/SocketModeTransportTests.cs index b468c2a4..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://amer.smba.trafficmanager.net/v3/websockets/connect", - "https://apac.smba.trafficmanager.net/v3/websockets/connect", - "https://emea.smba.trafficmanager.net/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,44 +44,50 @@ 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(); } [Theory] - [InlineData("amer", "amer")] - [InlineData("emea", "emea")] - [InlineData("apac", "apac")] - [InlineData(" /emea/ ", "emea")] - public async Task StartAsync_ConnectsOneConfiguredGeo(string geo, string expectedGeo) + [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 { - Geos = [geo], + Geos = geos, }); - Task start = harness.Transport.StartAsync(); - FakeConnection connection = Assert.Single(await harness.Factory.WaitForAsync(1)); - Assert.Equal($"https://{expectedGeo}.smba.trafficmanager.net/v3/websockets/connect", connection.NegotiateUri.AbsoluteUri); - Assert.Equal([expectedGeo], harness.Transport.GeoList); - connection.Ready(); - await start; + FakeConnection[] connections = await harness.StartReadyAsync(); + + Assert.Equal( + geos.Select(geo => $"https://{geo.ToLowerInvariant()}.botapi.skype.com/v3/websockets/connect").Order(StringComparer.Ordinal), + SortedUris(connections)); + 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(null)] - [InlineData(new object[] { new string[0] })] [InlineData(new object[] { new string[] { null! } })] [InlineData(new object[] { new[] { "" } })] - [InlineData(new object[] { new[] { " " } })] - [InlineData(new object[] { new[] { "/" } })] - [InlineData(new object[] { new[] { " / / " } })] [InlineData(new object[] { new[] { "amer", "" } })] - [InlineData(new object[] { new[] { "amer", "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 = ["attacker.example#"] }).Transport); + } + [Fact] public void Constructor_RejectsNegativeStartupTimeout() { From 868d6dcaeca8a75fa791416df6c54f0792de2ae4 Mon Sep 17 00:00:00 2001 From: Teddy Arida-Moody Date: Mon, 9 Nov 2026 16:03:03 -0800 Subject: [PATCH 15/15] Document SDK-managed Socket Mode negotiation URLs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.Teams.Apps/SocketMode/SocketModeObserver.cs | 1 + src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs | 1 + 2 files changed, 2 insertions(+) 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 984b2224..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. ///