Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions samples/SocketModeBot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ This sample is an echo bot that receives activities over Socket Mode instead of

## What it shows

- `UseSocketMode()` on `AddTeamsBotApplication` to receive activities over Socket Mode using the default production endpoint (`https://botapi.skype.com`).
- One connection per geo (`amer`, `emea`, `apac`) by default for inbound coverage across regions. Override with `Geos = [...]` or point at a custom ring with `NegotiateBaseUrl` in the `UseSocketMode(socket => ...)` callback.
- `UseSocketMode()` on `AddTeamsBotApplication` to receive activities over Socket Mode.
- One connection per geo (`amer`, `emea`, `apac`) by default for inbound coverage across regions. Configure `Geos = [...]` in the `UseSocketMode(socket => ...)` callback to target specific geos from these three defaults.
- `<NoWarn>$(NoWarn);ExperimentalTeamsSocketMode</NoWarn>` 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.
Expand Down
6 changes: 5 additions & 1 deletion src/Microsoft.Teams.Apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,11 @@ Negotiation failures expose `SocketModeNegotiateException.StatusCode`, `.RetryAf
and `.IsAuthError` for HTTP 401/403. Downstream SignalR negotiation errors are not classified as terminal
service-negotiation rejections.

`UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `NegotiateBaseUrl` and `Geos`.
`UseSocketMode(configure)` accepts a `SocketModeOptions` callback to change only `Geos`.
Use this setting to target specific geos from the three defaults: `amer`, `emea`, and `apac`.
An empty geo list uses all three defaults.
Each geo's resolved connect/negotiate URL is logged at Information level during startup
under `Microsoft.Teams.Apps.SocketMode.SocketModeTransport`. The SignalR URL returned by negotiation is used unchanged.
Startup and readiness timeouts and reconnect backoff are managed internally by the SDK.
Keep-alive and server timeout use the SignalR client defaults of 15 and 30 seconds, respectively.
`UseSocketMode(bool)` turns Socket Mode on with the defaults, or off with `false`, which clears any earlier
Expand Down
2 changes: 1 addition & 1 deletion src/Microsoft.Teams.Apps/SocketMode/GeoSocket.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ internal sealed class GeoSocket : IAsyncDisposable
/// Initializes a supervisor for one geo.
/// </summary>
/// <param name="owner">The transport that owns this geo.</param>
/// <param name="geo">The geo identifier. Empty when the negotiate URL has no geo segment.</param>
/// <param name="geo">The geo identifier.</param>
/// <param name="negotiateUri">The negotiate endpoint for the geo.</param>
/// <param name="connectionFactory">Creates one connection per generation.</param>
/// <param name="logger">The logger for lifecycle events.</param>
Expand Down
1 change: 1 addition & 0 deletions src/Microsoft.Teams.Apps/SocketMode/SocketModeObserver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,6 @@ internal SocketModeObserver(Func<SocketModeTransport> getTransport, SocketModeEv
/// Gets the resolved negotiation endpoint for the first configured geo, for diagnostics.
/// Each geo connects to its own endpoint.
/// </summary>
/// <remarks>This URL is managed by the SDK and is for diagnostics only. Do not depend on it; it may change in the future.</remarks>
public Uri NegotiateUrl => _transport.Value.NegotiateUrl;
}
13 changes: 4 additions & 9 deletions src/Microsoft.Teams.Apps/SocketMode/SocketModeOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,17 @@ 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
/// (<c>Host.CreateApplicationBuilder</c>). 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.
/// </remarks>
[Experimental("ExperimentalTeamsSocketMode")]
public sealed class SocketModeOptions
{
/// <summary>
/// Gets or sets the base URL used to negotiate each geo connection. Must use HTTPS unless it targets loopback.
/// Defaults to <c>https://botapi.skype.com</c>. Override this to target a different Teams backend service ring.
/// </summary>
public Uri NegotiateBaseUrl { get; set; } = new(SocketModeProtocol.DefaultNegotiateBaseUrl);

/// <summary>
/// Gets or sets the geos to connect, one connection each. An empty string connects to the base URL without a geo
/// segment. Defaults to <c>amer</c>, <c>emea</c>, and <c>apac</c>. Override this to connect to a different set of geos.
/// Gets or sets the geos to connect, one connection each.
/// Defaults to <c>amer</c>, <c>emea</c>, and <c>apac</c>, including when the list is empty.
/// Override this to connect to a different set of geos.
Comment thread
teddyam marked this conversation as resolved.
Resolved
/// </summary>
public IReadOnlyList<string> Geos { get; set; } = SocketModeProtocol.DefaultGeos;

Expand All @@ -38,7 +34,6 @@ public sealed class SocketModeOptions
/// <returns>The transport options.</returns>
internal SocketModeTransportOptions ToTransportOptions() => new()
{
NegotiateBaseUri = NegotiateBaseUrl,
Geos = Geos is null ? null! : [.. Geos],
};
}
6 changes: 3 additions & 3 deletions src/Microsoft.Teams.Apps/SocketMode/SocketModeProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ internal static class SocketModeProtocol
internal const int CurrentVersion = 1;

/// <summary>
/// The default base URL for Socket Mode negotiation.
/// The service hostname used for Socket Mode negotiation.
/// </summary>
internal const string DefaultNegotiateBaseUrl = "https://botapi.skype.com";
internal const string NegotiateHost = "botapi.skype.com";

/// <summary>
/// The default geographies for Socket Mode connections.
Expand All @@ -25,7 +25,7 @@ internal static class SocketModeProtocol
Array.AsReadOnly(["amer", "emea", "apac"]);

/// <summary>
/// The negotiate path appended after the geography segment.
/// The negotiate path appended to each geo's base URL.
/// </summary>
internal const string NegotiatePath = "/v3/websockets/connect";

Expand Down
58 changes: 33 additions & 25 deletions src/Microsoft.Teams.Apps/SocketMode/SocketModeTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,7 @@ public enum SocketModeStatus
internal sealed class SocketModeTransportOptions
{
/// <summary>
/// Gets the base URL used to negotiate each geo connection.
/// </summary>
internal Uri NegotiateBaseUri { get; init; } = new(SocketModeProtocol.DefaultNegotiateBaseUrl);

/// <summary>
/// Gets the geos to connect. An empty string connects to the base URL without a geo segment.
/// Gets the geos to connect. An empty list uses the default geos.
/// </summary>
internal IReadOnlyList<string> Geos { get; init; } = SocketModeProtocol.DefaultGeos;

Expand Down Expand Up @@ -117,7 +112,7 @@ internal sealed class SocketModeTransport : IGeoSocketOwner, IAsyncDisposable
/// <param name="timeProvider">The time provider for supervision timing and reply timestamps.</param>
/// <param name="random">The random source for reconnect jitter.</param>
/// <param name="events">The optional public lifecycle observers.</param>
/// <exception cref="ArgumentException">Thrown when the geos are empty, null, or duplicated.</exception>
/// <exception cref="ArgumentException">Thrown when the geo list is null or contains null, empty, or duplicate entries.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown when the startup timeout is negative.</exception>
internal SocketModeTransport(
SocketModeTransportOptions options,
Expand All @@ -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);
}

/// <summary>
Expand Down Expand Up @@ -241,6 +235,14 @@ internal async Task StartAsync(CancellationToken cancellationToken = default)
geoSockets.Length,
string.Join(", ", geoSockets.Select(geoSocket => geoSocket.Geo)));

foreach (var geo in _geos)
{
_logger.LogInformation(
Comment thread
teddyam marked this conversation as resolved.
Resolved
"Socket Mode geo {Geo} negotiate URL {NegotiateUrl}.",
geo.Geo,
geo.NegotiateUri);
}

using CancellationTokenSource startSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Exception? firstFailure = null;

Expand Down Expand Up @@ -494,17 +496,28 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ
TeamsActivityTypes.Invoke,
StringComparison.OrdinalIgnoreCase);

private static List<(string Geo, Uri NegotiateUri)> ResolveGeos(Uri baseUri, IReadOnlyList<string> geos)
private static List<(string Geo, Uri NegotiateUri)> ResolveGeos(IReadOnlyList<string> geos)
{
if (geos is null || geos.Count == 0)
if (geos is null)
{
throw new ArgumentException("Socket Mode geos cannot be null.", nameof(geos));
}

if (geos.Count == 0)
{
throw new ArgumentException(
"Socket Mode requires at least one geo. Use an empty string to connect without a geo segment.",
nameof(geos));
geos = SocketModeProtocol.DefaultGeos;
}

if (geos.Contains(string.Empty))
{
throw new ArgumentException("Socket Mode geos must not contain an empty string.", nameof(geos));
}

if (new HashSet<string>(geos, StringComparer.Ordinal).Count != geos.Count)
{
throw new ArgumentException("Socket Mode geos must not contain duplicates.", nameof(geos));
}

string baseUrl = baseUri.AbsoluteUri.TrimEnd('/');
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
List<(string Geo, Uri NegotiateUri)> resolved = [];

foreach (string geo in geos)
Expand All @@ -514,16 +527,11 @@ private static bool IsInvoke(SocketActivityEnvelope envelope, CoreActivity activ
throw new ArgumentException("Socket Mode geos cannot contain null.", nameof(geos));
}

string segment = geo.Trim().Trim('/');
if (!seen.Add(segment))
UriBuilder builder = new(Uri.UriSchemeHttps, $"{Uri.EscapeDataString(geo)}.{SocketModeProtocol.NegotiateHost}")
{
throw new ArgumentException($"Socket Mode geo '{geo}' is listed more than once.", nameof(geos));
}

string path = segment.Length > 0
? $"/{Uri.EscapeDataString(segment)}{SocketModeProtocol.NegotiatePath}"
: SocketModeProtocol.NegotiatePath;
resolved.Add((segment, new Uri(baseUrl + path, UriKind.Absolute)));
Path = SocketModeProtocol.NegotiatePath,
};
resolved.Add((geo, builder.Uri));
}

return resolved;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode;

public class GeoSocketTests
{
private static readonly Uri NegotiateUri = new("https://botapi.skype.com/amer/v3/websockets/connect");
private static readonly Uri NegotiateUri = new("https://amer.botapi.skype.com/v3/websockets/connect");

[Fact]
public async Task StartAsync_CompletesOnlyAfterSocketReady()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode;
public class SignalRSocketConnectionTests
{
private static readonly Uri NegotiateUri =
new("https://botapi.skype.com/amer/v3/websockets/connect");
new("https://amer.botapi.skype.com/v3/websockets/connect");
private static readonly Uri SignalRUri =
new("https://signalr.example.test/client");
new("https://signalr.example.test/client?hub=teams");

[Fact]
public async Task StartAsync_NegotiatesCreatesAndStartsConnection()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -168,7 +168,7 @@ protected override Task<HttpResponseMessage> 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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,11 @@ public void WithSocketMode_AcceptsPublicIssuerWithTrailingSlash()

public static TheoryData<Action<SocketModeOptions>> InvalidOptions => new()
{
o => o.Geos = [],
o => o.Geos = null!,
o => o.Geos = [""],
o => o.Geos = ["amer", ""],
o => o.Geos = ["amer", null!],
o => o.Geos = ["amer", " AMER/"],
o => o.Geos = ["amer", "amer"],
};

[Theory]
Expand Down Expand Up @@ -135,7 +137,7 @@ public async Task Negotiator_UsesTheBotTokenForTheBotFrameworkScopeOnAPlainClien

using ServiceProvider provider = services.BuildServiceProvider();
SocketModeNegotiateResponse response = await provider.GetRequiredService<ISocketModeNegotiator>()
.NegotiateAsync(new Uri("https://botapi.skype.com/amer/v3/websockets/connect"));
.NegotiateAsync(new Uri("https://amer.botapi.skype.com/v3/websockets/connect"));

Assert.Equal("signalr-token", response.AccessToken);
Assert.Equal(["https://api.botframework.com/.default"], scopes);
Expand Down Expand Up @@ -165,7 +167,7 @@ public async Task HostedService_StartsEveryGeoDispatchesToTheAppAndStops()
SocketModeTransport transport = provider.GetRequiredService<SocketModeTransport>();
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,12 @@ namespace Microsoft.Teams.Apps.UnitTests.SocketMode;
public class SocketModeNegotiatorTests
{
private const string BotToken = "bot-token";
private static readonly Uri NegotiateUri = new("https://botapi.skype.com/amer/v3/websockets/connect");
private static readonly Uri NegotiateUri = new("https://amer.botapi.skype.com/v3/websockets/connect");

[Theory]
[InlineData("https://botapi.skype.com/v3/websockets/connect")]
[InlineData("http://localhost:5000/v3/websockets/connect")]
[InlineData("http://127.0.0.1:5000/v3/websockets/connect")]
[InlineData("http://[::1]:5000/v3/websockets/connect")]
public async Task NegotiateAsync_AllowsSecureAndLoopbackUris(string uri)
{
RecordingHandler handler = SuccessHandler();
SocketModeNegotiator negotiator = CreateNegotiator(handler);

SocketModeNegotiateResponse response = await negotiator.NegotiateAsync(new Uri(uri));

Assert.Equal("https://signalr.example.test/client", response.Url);
Assert.Equal(1, handler.SendCount);
}

[Theory]
[InlineData("http://botapi.skype.com/v3/websockets/connect")]
[InlineData("http://127.0.0.2/v3/websockets/connect")]
[InlineData("wss://botapi.skype.com/v3/websockets/connect")]
[InlineData("ws://localhost:5000/v3/websockets/connect")]
[InlineData("ftp://botapi.skype.com/v3/websockets/connect")]
[InlineData("http://amer.botapi.skype.com/v3/websockets/connect")]
[InlineData("wss://amer.botapi.skype.com/v3/websockets/connect")]
[InlineData("ftp://amer.botapi.skype.com/v3/websockets/connect")]
public async Task NegotiateAsync_RejectsUnsupportedNegotiateUris(string uri)
{
RecordingHandler handler = SuccessHandler();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -32,8 +31,8 @@ public async Task Diagnostics_AreLazyAndDoNotConnect()
observer.Events.Ready += (_, _) => { };
Assert.Equal(0, created);
Assert.Equal(SocketModeStatus.Idle, observer.Status);
Assert.Equal(new Uri("https://canary.botapi.skype.com/eu/v3/websockets/connect"), observer.NegotiateUrl);
Assert.Equal(["eu"], observer.GeoList);
Assert.Equal(new Uri("https://emea.botapi.skype.com/v3/websockets/connect"), observer.NegotiateUrl);
Assert.Equal(["emea"], observer.GeoList);
Assert.Empty(observer.GeoStatuses);
Assert.Equal(1, created);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
{
Expand All @@ -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);
Expand All @@ -45,19 +44,17 @@ public void ToTransportOptions_PassesInvalidValuesThroughForTheTransportToReject
}

[Fact]
public void ToTransportOptions_CopiesEndpointAndSnapshotsGeos()
public void ToTransportOptions_SnapshotsGeos()
{
List<string> 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);
}

Expand Down
Loading
Loading