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
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, ...]:
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


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