Skip to content
10 changes: 5 additions & 5 deletions packages/apps/src/microsoft_teams/apps/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,11 @@ class AppOptions(TypedDict, total=False):
socket_mode: Optional[Union[bool, SocketModeOptions]]
"""Experimental: receive activities over an outbound WebSocket instead of an inbound HTTPS endpoint.

``True`` enables it with defaults; pass a ``SocketModeOptions`` to configure
``negotiate_base_url`` and ``geos``. The bot dials the Teams service, so no public endpoint or dev tunnel is
needed — but HTTP-only features (``app.function()``, tabs, OAuth redirect routes)
have no listener to serve them. Mutually exclusive with ``http_server_adapter``,
and supported only in the public cloud.
``True`` enables it with defaults; pass a ``SocketModeOptions`` to select geo
regions. The bot dials the Teams service, so no public endpoint
or dev tunnel is needed — but HTTP-only features (``app.function()``, tabs, OAuth
redirect routes) have no listener to serve them. Mutually exclusive with
``http_server_adapter``, and supported only in the public cloud.

Socket Mode is currently only available for new bots.
This API is in preview and may change in the future; recommended only for development.
Expand Down
24 changes: 4 additions & 20 deletions packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,9 @@
from .envelope import build_reply_frame, is_invoke_envelope, read_envelope_activity
from .transport import (
DEFAULT_GEOS,
DEFAULT_SOCKET_MODE_NEGOTIATE_BASE_URL,
SocketModeTransport,
SocketModeTransportOptions,
build_negotiate_url,
resolve_geos,
)
from .types import (
SOCKET_MODE_PROTOCOL_VERSION,
Expand Down Expand Up @@ -79,19 +78,13 @@ class SocketModeReconnectedEvent:
@dataclass(frozen=True)
class SocketModeOptions:
"""
Preview configuration for the Socket Mode negotiate URL and geographies; may change in the future.
Preview configuration for Socket Mode geographies; may change in the future.

WebSocket is only recommended for use when developing agents.
Socket Mode is currently only available for new bots.
Diagnostic: ExperimentalTeamsSocketMode
"""

negotiate_base_url: str = DEFAULT_SOCKET_MODE_NEGOTIATE_BASE_URL
"""
Service host used to negotiate the connection. Defaults to ``https://botapi.skype.com``.
Override this to target a different Teams backend service ring.
"""

geos: Sequence[str] = DEFAULT_GEOS
"""
Geographies to connect, one independent socket each. Defaults to ``("amer", "emea", "apac")``;
Expand All @@ -100,10 +93,7 @@ class SocketModeOptions:
"""

def to_transport_options(self) -> SocketModeTransportOptions:
return SocketModeTransportOptions(
negotiate_base_url=self.negotiate_base_url,
geos=tuple(self.geos),
)
return SocketModeTransportOptions(geos=tuple(self.geos))


@dataclass(frozen=True)
Expand Down Expand Up @@ -196,13 +186,7 @@ def geo_statuses(self) -> tuple[tuple[str, SocketModeStatus], ...]:
@property
def geo_list(self) -> tuple[str, ...]:
"""The geos this adapter connects to."""
return tuple(self.options.geos)

@property
def negotiate_url(self) -> str:
"""Resolved negotiate URL for the first geo. Each geo dials its own."""
geos = self.geo_list
return build_negotiate_url(self.options.negotiate_base_url, geos[0] if geos else "")
return resolve_geos(self.options.geos)

def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None:
"""
Expand Down
45 changes: 23 additions & 22 deletions packages/apps/src/microsoft_teams/apps/socket_mode/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import random
from dataclasses import dataclass
from typing import Awaitable, Callable, Optional, Sequence
from urllib.parse import quote

import httpx2

Expand All @@ -37,11 +36,11 @@
WebSocketFactory,
)

DEFAULT_SOCKET_MODE_NEGOTIATE_BASE_URL = "https://botapi.skype.com"
"""Default service host used to negotiate the connection."""
SOCKET_MODE_NEGOTIATE_HOST = "botapi.skype.com"
"""Service-owned host used to negotiate Socket Mode connections."""

SOCKET_MODE_NEGOTIATE_PATH = "/v3/websockets/connect"
"""Negotiate route appended to the base URL: ``POST {base}/{geo}/v3/websockets/connect``."""
"""Negotiate route appended to the geo-specific service host."""

DEFAULT_GEOS = ("amer", "emea", "apac")
"""Geographies connected by default, one independent socket each."""
Expand Down Expand Up @@ -73,12 +72,10 @@ class SocketModeTransportOptions:
(the TypeScript SDK uses milliseconds with an ``Ms`` suffix).
"""

negotiate_base_url: str = DEFAULT_SOCKET_MODE_NEGOTIATE_BASE_URL
geos: Sequence[str] = DEFAULT_GEOS
"""
Every listed geo must connect for ``start()`` to succeed; each is then supervised
independently, so one geo dropping never affects the others. Pass ``("",)`` to
connect to the base URL with no geo segment.
independently, so one geo dropping never affects the others.
"""

readiness_timeout: float = 30.0
Expand Down Expand Up @@ -188,18 +185,12 @@ async def start(self) -> None:
return
if self._options.startup_timeout < 0:
raise ValueError("Socket Mode startup_timeout must be non-negative")
geos = tuple(self._options.geos)
if not geos:
raise ValueError("Socket Mode geos must contain at least one entry")
if len(set(geos)) != len(geos):
raise ValueError("Socket Mode geos must not contain duplicates")
geos = resolve_geos(self._options.geos)
negotiate_urls = tuple((geo, build_negotiate_url(geo)) for geo in geos)

self._stop_event = asyncio.Event()
self._lifecycle = SocketModeStatus.CONNECTING
self._geos = [
GeoSocket(self, geo, build_negotiate_url(self._options.negotiate_base_url, geo), self._logger)
for geo in geos
]
self._geos = [GeoSocket(self, geo, negotiate_url, self._logger) for geo, negotiate_url in negotiate_urls]
tasks = [
asyncio.create_task(geo.start_initial(), name=f"teams-socket-mode-start-{geo or 'default'}")
for geo in self._geos
Expand Down Expand Up @@ -343,9 +334,19 @@ def _call_lifecycle(self, callback: Callable[..., None], *args: object) -> None:
self._logger.warning("Socket Mode lifecycle callback failed", exc_info=error)


def build_negotiate_url(base_url: str, geo: str) -> str:
base = base_url.rstrip("/")
segment = geo.strip().strip("/")
if segment:
return f"{base}/{quote(segment, safe='')}{SOCKET_MODE_NEGOTIATE_PATH}"
return f"{base}{SOCKET_MODE_NEGOTIATE_PATH}"
def resolve_geos(geos: Sequence[str]) -> tuple[str, ...]:
Comment thread
lilyydu marked this conversation as resolved.
Resolved
configured_geos = tuple(geos)
if not configured_geos:
return DEFAULT_GEOS
if "" in configured_geos:
raise ValueError("Socket Mode geos must not contain an empty string")
if len({geo.lower() for geo in configured_geos}) != len(configured_geos):
raise ValueError("Socket Mode geos must not contain duplicates")
return configured_geos
Comment thread
lilyydu marked this conversation as resolved.
Resolved


def build_negotiate_url(geo: str) -> str:
if geo == "":
raise ValueError("Socket Mode geo must not be an empty string")

return f"https://{geo}.{SOCKET_MODE_NEGOTIATE_HOST}{SOCKET_MODE_NEGOTIATE_PATH}"
43 changes: 29 additions & 14 deletions packages/apps/tests/test_socket_mode_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def make_adapter(
pipeline: Optional[RecordingPipeline] = None,
client_id: Optional[str] = "bot-client-id",
on_error: Any = None,
geos: tuple[str, ...] = ("",),
geos: tuple[str, ...] = ("amer",),
) -> tuple[SocketModeAdapter, RecordingPipeline]:
pipeline = pipeline or RecordingPipeline()
adapter = SocketModeAdapter(
Expand Down Expand Up @@ -377,9 +377,25 @@ async def test_status_and_geo_statuses_track_the_transport():
assert adapter.status == SocketModeStatus.STOPPED


def test_negotiate_url_resolves_the_first_geo():
adapter, _ = make_adapter(geos=("amer", "emea"))
assert adapter.negotiate_url == "https://botapi.skype.com/amer/v3/websockets/connect"
def test_empty_geo_sequence_uses_defaults():
adapter, _ = make_adapter(geos=())

assert adapter.geo_list == ("amer", "emea", "apac")


def test_empty_string_geo_is_rejected():
adapter, _ = make_adapter(geos=("amer", ""))

with pytest.raises(ValueError, match="must not contain an empty string"):
_ = adapter.geo_list


@pytest.mark.parametrize("geos", [("amer", "amer"), ("amer", "AMER")])
def test_duplicate_geos_are_rejected(geos: tuple[str, ...]):
adapter, _ = make_adapter(geos=geos)

with pytest.raises(ValueError, match="must not contain duplicates"):
_ = adapter.geo_list


def test_messaging_route_is_accepted_and_other_routes_warn(caplog: pytest.LogCaptureFixture):
Expand All @@ -404,13 +420,18 @@ def test_serve_static_warns(caplog: pytest.LogCaptureFixture):
assert "serve_static" in caplog.text


def test_options_expose_only_negotiate_base_url_and_geos():
expected = {"negotiate_base_url", "geos"}
def test_options_expose_only_geos():
expected = {"geos"}

assert {field.name for field in fields(SocketModeOptions)} == expected
assert set(inspect.signature(SocketModeOptions).parameters) == expected


def test_options_reject_negotiate_base_url_override():
with pytest.raises(TypeError, match="negotiate_base_url"):
SocketModeOptions(negotiate_base_url="https://botapi.example") # pyright: ignore[reportCallIssue]


@pytest.mark.parametrize(
"name",
["readiness_timeout", "startup_timeout", "reconnect_delays", "keep_alive_interval", "server_timeout"],
Expand All @@ -430,7 +451,6 @@ def test_options_defaults_preserve_transport_policy():
options = SocketModeOptions()
transport_options = options.to_transport_options()

assert transport_options.negotiate_base_url == "https://botapi.skype.com"
assert transport_options.geos == ("amer", "emea", "apac")
assert transport_options.readiness_timeout == 30
assert transport_options.startup_timeout == 30
Expand All @@ -441,14 +461,10 @@ def test_options_defaults_preserve_transport_policy():

def test_options_map_onto_transport_options():
geos = ["amer"]
options = SocketModeOptions(
negotiate_base_url="https://botapi.example",
geos=geos,
)
options = SocketModeOptions(geos=geos)
transport_options = options.to_transport_options()
geos.append("emea")

assert transport_options.negotiate_base_url == "https://botapi.example"
assert transport_options.geos == ("amer",)


Expand Down Expand Up @@ -532,12 +548,11 @@ def test_app_socket_mode_options_are_honoured():
client_id="client",
client_secret="secret",
tenant_id="tenant",
socket_mode=SocketModeOptions(negotiate_base_url="https://botapi.example", geos=("amer",)),
socket_mode=SocketModeOptions(geos=("amer",)),
)

assert app.socket_mode is not None
assert app.socket_mode.geo_list == ("amer",)
assert app.socket_mode.negotiate_url == "https://botapi.example/amer/v3/websockets/connect"


def test_app_rejects_socket_mode_with_a_custom_http_adapter():
Expand Down
6 changes: 5 additions & 1 deletion packages/apps/tests/test_socket_mode_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ async def close(self) -> None:

def make_http_client(*, hub_url: str = "https://signalr.example/client/?hub=teams") -> httpx2.AsyncClient:
def handler(request: httpx2.Request) -> httpx2.Response:
if request.url.host == "botapi.example":
if (
request.url.host == "botapi.example"
or request.url.host.endswith(".botapi.example")
or request.url.host.endswith(".botapi.skype.com")
):
return httpx2.Response(
200,
json={"url": hub_url, "accessToken": "signalr-token", "expiresIn": 120},
Expand Down
Loading
Loading