From acf51ebf459cf04dd1e588b224569c2dd59da7d7 Mon Sep 17 00:00:00 2001 From: lilyydu <54044854+lilyydu@users.noreply.github.com> Date: Mon, 9 Nov 2026 10:55:37 -0800 Subject: [PATCH 01/10] update connect URL to target correct geo --- .../apps/socket_mode/transport.py | 22 +++++++++++-------- .../apps/tests/test_socket_mode_adapter.py | 4 ++-- .../apps/tests/test_socket_mode_connection.py | 2 +- .../apps/tests/test_socket_mode_transport.py | 16 +++++++++----- 4 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py index cc9ec8eb..09036fde 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py @@ -18,7 +18,7 @@ import random from dataclasses import dataclass from typing import Awaitable, Callable, Optional, Sequence -from urllib.parse import quote +from urllib.parse import urlsplit, urlunsplit import httpx2 @@ -41,7 +41,7 @@ """Default service host used to negotiate the connection.""" 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.""" @@ -196,10 +196,11 @@ async def start(self) -> None: 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 = [] + for geo in geos: + negotiate_url = build_negotiate_url(self._options.negotiate_base_url, geo) + self._logger.debug("socket-mode[%s]: negotiate URL %s", geo or "default", negotiate_url) + self._geos.append(GeoSocket(self, geo, negotiate_url, self._logger)) tasks = [ asyncio.create_task(geo.start_initial(), name=f"teams-socket-mode-start-{geo or 'default'}") for geo in self._geos @@ -346,6 +347,9 @@ def _call_lifecycle(self, callback: Callable[..., None], *args: object) -> None: 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}" + if not segment: + return f"{base}{SOCKET_MODE_NEGOTIATE_PATH}" + + parsed = urlsplit(base) + path = f"{parsed.path.rstrip('/')}{SOCKET_MODE_NEGOTIATE_PATH}" + return urlunsplit((parsed.scheme, f"{segment}.{parsed.netloc}", path, parsed.query, "")) diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index 1522f76e..c18fdf83 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -379,7 +379,7 @@ async def test_status_and_geo_statuses_track_the_transport(): 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" + assert adapter.negotiate_url == "https://amer.botapi.skype.com/v3/websockets/connect" def test_messaging_route_is_accepted_and_other_routes_warn(caplog: pytest.LogCaptureFixture): @@ -537,7 +537,7 @@ def test_app_socket_mode_options_are_honoured(): 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" + assert app.socket_mode.negotiate_url == "https://amer.botapi.example/v3/websockets/connect" def test_app_rejects_socket_mode_with_a_custom_http_adapter(): diff --git a/packages/apps/tests/test_socket_mode_connection.py b/packages/apps/tests/test_socket_mode_connection.py index ef8e78f9..0fa6510e 100644 --- a/packages/apps/tests/test_socket_mode_connection.py +++ b/packages/apps/tests/test_socket_mode_connection.py @@ -46,7 +46,7 @@ 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"): return httpx2.Response( 200, json={"url": hub_url, "accessToken": "signalr-token", "expiresIn": 120}, diff --git a/packages/apps/tests/test_socket_mode_transport.py b/packages/apps/tests/test_socket_mode_transport.py index 999e0df7..dd0c59c2 100644 --- a/packages/apps/tests/test_socket_mode_transport.py +++ b/packages/apps/tests/test_socket_mode_transport.py @@ -207,18 +207,21 @@ async def dispatch(envelope: SocketActivityEnvelope) -> Optional[ReplyFrame]: @pytest.mark.asyncio -async def test_transport_starts_all_geos_and_stops_every_connection(): +async def test_transport_starts_all_geos_and_stops_every_connection(caplog: pytest.LogCaptureFixture): factory = MockConnectionFactory() transport = await make_transport(factory, geos=("amer", "emea", "apac")) + caplog.set_level(logging.DEBUG, logger="microsoft_teams.apps.socket_mode.transport") await transport.start() assert transport.status == SocketModeStatus.READY - assert factory.urls == [ - "https://botapi.skype.com/amer/v3/websockets/connect", - "https://botapi.skype.com/emea/v3/websockets/connect", - "https://botapi.skype.com/apac/v3/websockets/connect", + expected_urls = [ + "https://amer.botapi.skype.com/v3/websockets/connect", + "https://emea.botapi.skype.com/v3/websockets/connect", + "https://apac.botapi.skype.com/v3/websockets/connect", ] + assert factory.urls == expected_urls + assert all(url in caplog.text for url in expected_urls) await transport.stop() assert transport.status == SocketModeStatus.STOPPED assert all(connection.stopped >= 1 for connection in factory.connections) @@ -255,7 +258,7 @@ def negotiate(request: httpx2.Request) -> httpx2.Response: }, ) service_requests.append(request) - geo = request.url.path.split("/")[1] + geo = request.url.host.split(".", 1)[0] issued_tokens.append(f"service-token-{len(service_requests)}") return httpx2.Response( 200, @@ -1085,6 +1088,7 @@ async def test_shutdown_suppresses_late_negotiate_rejection(status: int, caplog: factory, callbacks=SocketModeCallbacks(on_disconnected=lambda *args: events.append(args)) ) await transport.start() + caplog.clear() first = factory.connections[0] first.stop_gate = asyncio.Event() factory.start_gate = asyncio.Event() From f45bc15ce471455807b4d00f7ce9774c33ca26ba Mon Sep 17 00:00:00 2001 From: lilyydu <54044854+lilyydu@users.noreply.github.com> Date: Mon, 9 Nov 2026 11:59:32 -0800 Subject: [PATCH 02/10] address comments - remove extra log, check valid geos, add extra tests --- .../apps/socket_mode/transport.py | 19 ++++---- .../apps/tests/test_socket_mode_adapter.py | 16 +++++-- .../apps/tests/test_socket_mode_transport.py | 47 +++++++++++++++---- 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py index 09036fde..46e5494d 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py @@ -77,8 +77,8 @@ class SocketModeTransportOptions: 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. Supported values are + ``"amer"``, ``"emea"``, and ``"apac"``. """ readiness_timeout: float = 30.0 @@ -193,13 +193,15 @@ async def start(self) -> None: 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") + unsupported_geos = tuple(geo for geo in geos if geo not in DEFAULT_GEOS) + if unsupported_geos: + raise ValueError(f"Socket Mode geos must be selected from {DEFAULT_GEOS}: {unsupported_geos!r}") self._stop_event = asyncio.Event() self._lifecycle = SocketModeStatus.CONNECTING self._geos = [] for geo in geos: negotiate_url = build_negotiate_url(self._options.negotiate_base_url, geo) - self._logger.debug("socket-mode[%s]: negotiate URL %s", geo or "default", negotiate_url) self._geos.append(GeoSocket(self, geo, negotiate_url, self._logger)) tasks = [ asyncio.create_task(geo.start_initial(), name=f"teams-socket-mode-start-{geo or 'default'}") @@ -345,11 +347,12 @@ def _call_lifecycle(self, callback: Callable[..., None], *args: object) -> None: def build_negotiate_url(base_url: str, geo: str) -> str: - base = base_url.rstrip("/") - segment = geo.strip().strip("/") - if not segment: - return f"{base}{SOCKET_MODE_NEGOTIATE_PATH}" + # Only service-defined geos may be added to the negotiate hostname. + if geo not in DEFAULT_GEOS: + raise ValueError(f"Socket Mode geo must be one of {DEFAULT_GEOS}: {geo!r}") + base = base_url.rstrip("/") parsed = urlsplit(base) + # Regional endpoints use the geo as a subdomain and retain any configured base path and query. path = f"{parsed.path.rstrip('/')}{SOCKET_MODE_NEGOTIATE_PATH}" - return urlunsplit((parsed.scheme, f"{segment}.{parsed.netloc}", path, parsed.query, "")) + return urlunsplit((parsed.scheme, f"{geo}.{parsed.netloc}", path, parsed.query, "")) diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index c18fdf83..82169e4a 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -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( @@ -377,9 +377,17 @@ 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://amer.botapi.skype.com/v3/websockets/connect" +@pytest.mark.parametrize( + ("geo", "expected_url"), + [ + ("amer", "https://amer.botapi.skype.com/v3/websockets/connect"), + ("emea", "https://emea.botapi.skype.com/v3/websockets/connect"), + ("apac", "https://apac.botapi.skype.com/v3/websockets/connect"), + ], +) +def test_negotiate_url_resolves_the_first_geo(geo: str, expected_url: str): + adapter, _ = make_adapter(geos=(geo,)) + assert adapter.negotiate_url == expected_url def test_messaging_route_is_accepted_and_other_routes_warn(caplog: pytest.LogCaptureFixture): diff --git a/packages/apps/tests/test_socket_mode_transport.py b/packages/apps/tests/test_socket_mode_transport.py index dd0c59c2..5c8acd95 100644 --- a/packages/apps/tests/test_socket_mode_transport.py +++ b/packages/apps/tests/test_socket_mode_transport.py @@ -180,9 +180,10 @@ async def on_activity(_: SocketActivityEnvelope) -> Optional[ReplyFrame]: async def make_transport( factory: MockConnectionFactory, *, - geos: tuple[str, ...] = ("",), + geos: tuple[str, ...] = ("amer",), callbacks: Optional[SocketModeCallbacks] = None, on_activity: Optional[Callable[[SocketActivityEnvelope], object]] = None, + negotiate_base_url: str = "https://botapi.skype.com", startup_timeout: float = 1, reconnect_delays: tuple[float, ...] = (0,), ) -> SocketModeTransport: @@ -196,6 +197,7 @@ async def dispatch(envelope: SocketActivityEnvelope) -> Optional[ReplyFrame]: return SocketModeTransport( SocketModeTransportOptions( geos=geos, + negotiate_base_url=negotiate_base_url, startup_timeout=startup_timeout, reconnect_delays=reconnect_delays, ), @@ -207,11 +209,10 @@ async def dispatch(envelope: SocketActivityEnvelope) -> Optional[ReplyFrame]: @pytest.mark.asyncio -async def test_transport_starts_all_geos_and_stops_every_connection(caplog: pytest.LogCaptureFixture): +async def test_transport_starts_all_geos_and_stops_every_connection(): factory = MockConnectionFactory() transport = await make_transport(factory, geos=("amer", "emea", "apac")) - caplog.set_level(logging.DEBUG, logger="microsoft_teams.apps.socket_mode.transport") await transport.start() assert transport.status == SocketModeStatus.READY @@ -221,12 +222,38 @@ async def test_transport_starts_all_geos_and_stops_every_connection(caplog: pyte "https://apac.botapi.skype.com/v3/websockets/connect", ] assert factory.urls == expected_urls - assert all(url in caplog.text for url in expected_urls) await transport.stop() assert transport.status == SocketModeStatus.STOPPED assert all(connection.stopped >= 1 for connection in factory.connections) +@pytest.mark.parametrize("geo", ["attacker.example#", "", "eur", "AMER"]) +@pytest.mark.asyncio +async def test_transport_rejects_unsupported_geo_before_connecting(geo: str): + factory = MockConnectionFactory() + transport = await make_transport(factory, geos=(geo,)) + + with pytest.raises(ValueError, match="must be selected from"): + await transport.start() + + assert transport.status == SocketModeStatus.IDLE + assert factory.connections == [] + + +@pytest.mark.asyncio +async def test_transport_preserves_custom_port(): + factory = MockConnectionFactory() + transport = await make_transport( + factory, + negotiate_base_url="https://botapi.example:8443", + ) + + await transport.start() + + assert factory.urls == ["https://amer.botapi.example:8443/v3/websockets/connect"] + await transport.stop() + + @pytest.mark.asyncio async def test_socket_ids_remain_independent_across_geos_and_handoff(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(geo_socket, "CONNECTION_HANDOFF_SECONDS", 30.0) @@ -571,8 +598,8 @@ async def test_drop_reconnects_and_fences_superseded_generation(): assert stale_reply is None assert current_reply is not None assert dispatched == ["current"] - assert disconnected == [""] - assert reconnected == [""] + assert disconnected == ["amer"] + assert reconnected == ["amer"] await transport.stop() @@ -699,7 +726,7 @@ async def test_transport_stop_is_idempotent_and_transport_can_restart(): def test_reconnect_schedule_caps_at_last_configured_delay(): factory = MockConnectionFactory() transport = SocketModeTransport( - SocketModeTransportOptions(geos=("",), reconnect_delays=(0.25, 0.5)), + SocketModeTransportOptions(geos=("amer",), reconnect_delays=(0.25, 0.5)), get_bot_token=lambda: _token("token"), on_activity=lambda _: _reply(), connection_factory=factory, @@ -714,7 +741,7 @@ def test_exponential_backoff_survives_a_long_failure_streak(): """Without a clamp on the exponent, ``2**attempt`` overflows the float conversion.""" factory = MockConnectionFactory() transport = SocketModeTransport( - SocketModeTransportOptions(geos=("",), reconnect_delays=()), + SocketModeTransportOptions(geos=("amer",), reconnect_delays=()), get_bot_token=lambda: _token("token"), on_activity=lambda _: _reply(), connection_factory=factory, @@ -937,8 +964,8 @@ def rotation_in_flight() -> bool: await _eventually(rotation_in_flight, timeout=1.5) previous.drop(ConnectionError("died mid-rotation")) - await _eventually(lambda: disconnected == [""], timeout=2.0) - await _eventually(lambda: reconnected == [""], timeout=2.0) + await _eventually(lambda: disconnected == ["amer"], timeout=2.0) + await _eventually(lambda: reconnected == ["amer"], timeout=2.0) assert transport.status == SocketModeStatus.READY await transport.stop() From f855083c0b0e656b5ab6d4bdcf59f2f5d0d8cf08 Mon Sep 17 00:00:00 2001 From: Lily Du Date: Mon, 9 Nov 2026 12:25:05 -0800 Subject: [PATCH 03/10] Implement tests for negotiate_url functionality Add tests for negotiate_url in SocketModeAdapter. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../apps/tests/test_socket_mode_adapter.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index 82169e4a..25b1d946 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -390,6 +390,24 @@ def test_negotiate_url_resolves_the_first_geo(geo: str, expected_url: str): assert adapter.negotiate_url == expected_url +@pytest.mark.parametrize("geo", ["attacker.example#", "", "eur", "AMER"]) +def test_negotiate_url_rejects_unsupported_geo(geo: str): + adapter, _ = make_adapter(geos=(geo,)) + + with pytest.raises(ValueError, match="Socket Mode geo must be one of"): + _ = adapter.negotiate_url + + +def test_negotiate_url_preserves_base_path_and_query(): + adapter = SocketModeAdapter( + SocketModeOptions(negotiate_base_url="https://botapi.example/ring?x=1", geos=("amer",)), + process_activity=RecordingPipeline(), + get_app_token=_app_token, + ) + + assert adapter.negotiate_url == "https://amer.botapi.example/ring/v3/websockets/connect?x=1" + + def test_messaging_route_is_accepted_and_other_routes_warn(caplog: pytest.LogCaptureFixture): adapter, _ = make_adapter() From c1e0ea21ea1f2e135a34ee9e42e9dcccab2f3875 Mon Sep 17 00:00:00 2001 From: Lily Du Date: Mon, 9 Nov 2026 12:25:38 -0800 Subject: [PATCH 04/10] Refactor test parameters for negotiate_url test Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/apps/tests/test_socket_mode_adapter.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index 25b1d946..b95f1127 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -378,15 +378,15 @@ async def test_status_and_geo_statuses_track_the_transport(): @pytest.mark.parametrize( - ("geo", "expected_url"), + ("geos", "expected_url"), [ - ("amer", "https://amer.botapi.skype.com/v3/websockets/connect"), - ("emea", "https://emea.botapi.skype.com/v3/websockets/connect"), - ("apac", "https://apac.botapi.skype.com/v3/websockets/connect"), + (("amer", "emea"), "https://amer.botapi.skype.com/v3/websockets/connect"), + (("emea", "apac"), "https://emea.botapi.skype.com/v3/websockets/connect"), + (("apac", "amer"), "https://apac.botapi.skype.com/v3/websockets/connect"), ], ) -def test_negotiate_url_resolves_the_first_geo(geo: str, expected_url: str): - adapter, _ = make_adapter(geos=(geo,)) +def test_negotiate_url_resolves_the_first_geo(geos: tuple[str, ...], expected_url: str): + adapter, _ = make_adapter(geos=geos) assert adapter.negotiate_url == expected_url From 9c078de03e353cd86b81072c1b7f28701581719b Mon Sep 17 00:00:00 2001 From: lilyydu <54044854+lilyydu@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:44:53 -0800 Subject: [PATCH 05/10] fix slash issue and error propagation for connection --- .../microsoft_teams/apps/socket_mode/transport.py | 9 +++------ packages/apps/tests/test_socket_mode_adapter.py | 4 ++-- packages/apps/tests/test_socket_mode_transport.py | 12 ++++++++++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py index 46e5494d..233adc90 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py @@ -196,13 +196,11 @@ async def start(self) -> None: unsupported_geos = tuple(geo for geo in geos if geo not in DEFAULT_GEOS) if unsupported_geos: raise ValueError(f"Socket Mode geos must be selected from {DEFAULT_GEOS}: {unsupported_geos!r}") + negotiate_urls = tuple((geo, build_negotiate_url(self._options.negotiate_base_url, geo)) for geo in geos) self._stop_event = asyncio.Event() self._lifecycle = SocketModeStatus.CONNECTING - self._geos = [] - for geo in geos: - negotiate_url = build_negotiate_url(self._options.negotiate_base_url, geo) - self._geos.append(GeoSocket(self, geo, negotiate_url, self._logger)) + 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 @@ -351,8 +349,7 @@ def build_negotiate_url(base_url: str, geo: str) -> str: if geo not in DEFAULT_GEOS: raise ValueError(f"Socket Mode geo must be one of {DEFAULT_GEOS}: {geo!r}") - base = base_url.rstrip("/") - parsed = urlsplit(base) + parsed = urlsplit(base_url) # Regional endpoints use the geo as a subdomain and retain any configured base path and query. path = f"{parsed.path.rstrip('/')}{SOCKET_MODE_NEGOTIATE_PATH}" return urlunsplit((parsed.scheme, f"{geo}.{parsed.netloc}", path, parsed.query, "")) diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index b95f1127..fade8795 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -400,12 +400,12 @@ def test_negotiate_url_rejects_unsupported_geo(geo: str): def test_negotiate_url_preserves_base_path_and_query(): adapter = SocketModeAdapter( - SocketModeOptions(negotiate_base_url="https://botapi.example/ring?x=1", geos=("amer",)), + SocketModeOptions(negotiate_base_url="https://botapi.example/ring?route=/", geos=("amer",)), process_activity=RecordingPipeline(), get_app_token=_app_token, ) - assert adapter.negotiate_url == "https://amer.botapi.example/ring/v3/websockets/connect?x=1" + assert adapter.negotiate_url == "https://amer.botapi.example/ring/v3/websockets/connect?route=/" def test_messaging_route_is_accepted_and_other_routes_warn(caplog: pytest.LogCaptureFixture): diff --git a/packages/apps/tests/test_socket_mode_transport.py b/packages/apps/tests/test_socket_mode_transport.py index 5c8acd95..6ffc32f1 100644 --- a/packages/apps/tests/test_socket_mode_transport.py +++ b/packages/apps/tests/test_socket_mode_transport.py @@ -240,6 +240,18 @@ async def test_transport_rejects_unsupported_geo_before_connecting(geo: str): assert factory.connections == [] +@pytest.mark.asyncio +async def test_transport_rejects_malformed_base_url_before_connecting(): + factory = MockConnectionFactory() + transport = await make_transport(factory, negotiate_base_url="https://[botapi.example") + + with pytest.raises(ValueError): + await transport.start() + + assert transport.status == SocketModeStatus.IDLE + assert factory.connections == [] + + @pytest.mark.asyncio async def test_transport_preserves_custom_port(): factory = MockConnectionFactory() From 1449b633bc957aa6e0ad7bb028987e2f443f9589 Mon Sep 17 00:00:00 2001 From: lilyydu <54044854+lilyydu@users.noreply.github.com> Date: Mon, 9 Nov 2026 14:28:32 -0800 Subject: [PATCH 06/10] remove negotiate override, update base URL --- .../apps/src/microsoft_teams/apps/options.py | 10 ++--- .../apps/socket_mode/adapter.py | 16 ++------ .../apps/socket_mode/transport.py | 15 +++---- .../apps/tests/test_socket_mode_adapter.py | 36 ++++++----------- .../apps/tests/test_socket_mode_connection.py | 6 ++- .../apps/tests/test_socket_mode_transport.py | 40 +++++-------------- 6 files changed, 42 insertions(+), 81 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index 5c264fd1..8fa543e2 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -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 one or + more supported 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. diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py b/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py index 4d1d2709..32812278 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py @@ -25,7 +25,6 @@ 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, @@ -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")``; @@ -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) @@ -202,7 +192,7 @@ def geo_list(self) -> tuple[str, ...]: 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 build_negotiate_url(geos[0] if geos else "") def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: """ diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py index 233adc90..5a093a9f 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py @@ -18,7 +18,6 @@ import random from dataclasses import dataclass from typing import Awaitable, Callable, Optional, Sequence -from urllib.parse import urlsplit, urlunsplit import httpx2 @@ -37,8 +36,8 @@ WebSocketFactory, ) -DEFAULT_SOCKET_MODE_NEGOTIATE_BASE_URL = "https://botapi.skype.com" -"""Default service host used to negotiate the connection.""" +SOCKET_MODE_NEGOTIATE_HOST = "smba.trafficmanager.net" +"""Service-owned host used to negotiate Socket Mode connections.""" SOCKET_MODE_NEGOTIATE_PATH = "/v3/websockets/connect" """Negotiate route appended to the geo-specific service host.""" @@ -73,7 +72,6 @@ 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 @@ -196,7 +194,7 @@ async def start(self) -> None: unsupported_geos = tuple(geo for geo in geos if geo not in DEFAULT_GEOS) if unsupported_geos: raise ValueError(f"Socket Mode geos must be selected from {DEFAULT_GEOS}: {unsupported_geos!r}") - negotiate_urls = tuple((geo, build_negotiate_url(self._options.negotiate_base_url, geo)) for geo in geos) + negotiate_urls = tuple((geo, build_negotiate_url(geo)) for geo in geos) self._stop_event = asyncio.Event() self._lifecycle = SocketModeStatus.CONNECTING @@ -344,12 +342,9 @@ 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: +def build_negotiate_url(geo: str) -> str: # Only service-defined geos may be added to the negotiate hostname. if geo not in DEFAULT_GEOS: raise ValueError(f"Socket Mode geo must be one of {DEFAULT_GEOS}: {geo!r}") - parsed = urlsplit(base_url) - # Regional endpoints use the geo as a subdomain and retain any configured base path and query. - path = f"{parsed.path.rstrip('/')}{SOCKET_MODE_NEGOTIATE_PATH}" - return urlunsplit((parsed.scheme, f"{geo}.{parsed.netloc}", path, parsed.query, "")) + return f"https://{geo}.{SOCKET_MODE_NEGOTIATE_HOST}{SOCKET_MODE_NEGOTIATE_PATH}" diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index fade8795..f27add76 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -380,9 +380,9 @@ async def test_status_and_geo_statuses_track_the_transport(): @pytest.mark.parametrize( ("geos", "expected_url"), [ - (("amer", "emea"), "https://amer.botapi.skype.com/v3/websockets/connect"), - (("emea", "apac"), "https://emea.botapi.skype.com/v3/websockets/connect"), - (("apac", "amer"), "https://apac.botapi.skype.com/v3/websockets/connect"), + (("amer", "emea"), "https://amer.smba.trafficmanager.net/v3/websockets/connect"), + (("emea", "apac"), "https://emea.smba.trafficmanager.net/v3/websockets/connect"), + (("apac", "amer"), "https://apac.smba.trafficmanager.net/v3/websockets/connect"), ], ) def test_negotiate_url_resolves_the_first_geo(geos: tuple[str, ...], expected_url: str): @@ -398,16 +398,6 @@ def test_negotiate_url_rejects_unsupported_geo(geo: str): _ = adapter.negotiate_url -def test_negotiate_url_preserves_base_path_and_query(): - adapter = SocketModeAdapter( - SocketModeOptions(negotiate_base_url="https://botapi.example/ring?route=/", geos=("amer",)), - process_activity=RecordingPipeline(), - get_app_token=_app_token, - ) - - assert adapter.negotiate_url == "https://amer.botapi.example/ring/v3/websockets/connect?route=/" - - def test_messaging_route_is_accepted_and_other_routes_warn(caplog: pytest.LogCaptureFixture): adapter, _ = make_adapter() @@ -430,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"], @@ -456,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 @@ -467,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",) @@ -558,12 +548,12 @@ 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://amer.botapi.example/v3/websockets/connect" + assert app.socket_mode.negotiate_url == "https://amer.smba.trafficmanager.net/v3/websockets/connect" def test_app_rejects_socket_mode_with_a_custom_http_adapter(): diff --git a/packages/apps/tests/test_socket_mode_connection.py b/packages/apps/tests/test_socket_mode_connection.py index 0fa6510e..56697927 100644 --- a/packages/apps/tests/test_socket_mode_connection.py +++ b/packages/apps/tests/test_socket_mode_connection.py @@ -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" or request.url.host.endswith(".botapi.example"): + if ( + request.url.host == "botapi.example" + or request.url.host.endswith(".botapi.example") + or request.url.host.endswith(".smba.trafficmanager.net") + ): return httpx2.Response( 200, json={"url": hub_url, "accessToken": "signalr-token", "expiresIn": 120}, diff --git a/packages/apps/tests/test_socket_mode_transport.py b/packages/apps/tests/test_socket_mode_transport.py index 6ffc32f1..9cde5561 100644 --- a/packages/apps/tests/test_socket_mode_transport.py +++ b/packages/apps/tests/test_socket_mode_transport.py @@ -183,7 +183,6 @@ async def make_transport( geos: tuple[str, ...] = ("amer",), callbacks: Optional[SocketModeCallbacks] = None, on_activity: Optional[Callable[[SocketActivityEnvelope], object]] = None, - negotiate_base_url: str = "https://botapi.skype.com", startup_timeout: float = 1, reconnect_delays: tuple[float, ...] = (0,), ) -> SocketModeTransport: @@ -197,7 +196,6 @@ async def dispatch(envelope: SocketActivityEnvelope) -> Optional[ReplyFrame]: return SocketModeTransport( SocketModeTransportOptions( geos=geos, - negotiate_base_url=negotiate_base_url, startup_timeout=startup_timeout, reconnect_delays=reconnect_delays, ), @@ -217,9 +215,9 @@ async def test_transport_starts_all_geos_and_stops_every_connection(): assert transport.status == SocketModeStatus.READY expected_urls = [ - "https://amer.botapi.skype.com/v3/websockets/connect", - "https://emea.botapi.skype.com/v3/websockets/connect", - "https://apac.botapi.skype.com/v3/websockets/connect", + "https://amer.smba.trafficmanager.net/v3/websockets/connect", + "https://emea.smba.trafficmanager.net/v3/websockets/connect", + "https://apac.smba.trafficmanager.net/v3/websockets/connect", ] assert factory.urls == expected_urls await transport.stop() @@ -227,45 +225,31 @@ async def test_transport_starts_all_geos_and_stops_every_connection(): assert all(connection.stopped >= 1 for connection in factory.connections) -@pytest.mark.parametrize("geo", ["attacker.example#", "", "eur", "AMER"]) @pytest.mark.asyncio -async def test_transport_rejects_unsupported_geo_before_connecting(geo: str): +async def test_transport_rejects_empty_geos_before_connecting(): factory = MockConnectionFactory() - transport = await make_transport(factory, geos=(geo,)) + transport = await make_transport(factory, geos=()) - with pytest.raises(ValueError, match="must be selected from"): + with pytest.raises(ValueError, match="must contain at least one entry"): await transport.start() assert transport.status == SocketModeStatus.IDLE assert factory.connections == [] +@pytest.mark.parametrize("geo", ["attacker.example#", "", "eur", "AMER"]) @pytest.mark.asyncio -async def test_transport_rejects_malformed_base_url_before_connecting(): +async def test_transport_rejects_unsupported_geo_before_connecting(geo: str): factory = MockConnectionFactory() - transport = await make_transport(factory, negotiate_base_url="https://[botapi.example") + transport = await make_transport(factory, geos=(geo,)) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="must be selected from"): await transport.start() assert transport.status == SocketModeStatus.IDLE assert factory.connections == [] -@pytest.mark.asyncio -async def test_transport_preserves_custom_port(): - factory = MockConnectionFactory() - transport = await make_transport( - factory, - negotiate_base_url="https://botapi.example:8443", - ) - - await transport.start() - - assert factory.urls == ["https://amer.botapi.example:8443/v3/websockets/connect"] - await transport.stop() - - @pytest.mark.asyncio async def test_socket_ids_remain_independent_across_geos_and_handoff(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(geo_socket, "CONNECTION_HANDOFF_SECONDS", 30.0) @@ -679,9 +663,7 @@ async def open_socket(_: str) -> MockWebSocket: monkeypatch.setattr(geo_socket.asyncio, "sleep", sleep) async with make_http_client() as client: transport = SocketModeTransport( - SocketModeTransportOptions( - geos=("amer",), reconnect_delays=(0,), negotiate_base_url="https://botapi.example" - ), + SocketModeTransportOptions(geos=("amer",), reconnect_delays=(0,)), get_bot_token=lambda: _token("token"), on_activity=lambda _: _reply(), http_client=client, From 424f50a253ae5c7c11a7e2ec217377e2784486b4 Mon Sep 17 00:00:00 2001 From: lilyydu <54044854+lilyydu@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:10:41 -0800 Subject: [PATCH 07/10] update to use botapi.skype --- .../src/microsoft_teams/apps/socket_mode/transport.py | 2 +- packages/apps/tests/test_socket_mode_adapter.py | 8 ++++---- packages/apps/tests/test_socket_mode_connection.py | 2 +- packages/apps/tests/test_socket_mode_transport.py | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py index 5a093a9f..92d1e76d 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py @@ -36,7 +36,7 @@ WebSocketFactory, ) -SOCKET_MODE_NEGOTIATE_HOST = "smba.trafficmanager.net" +SOCKET_MODE_NEGOTIATE_HOST = "botapi.skype.com" """Service-owned host used to negotiate Socket Mode connections.""" SOCKET_MODE_NEGOTIATE_PATH = "/v3/websockets/connect" diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index f27add76..e290a449 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -380,9 +380,9 @@ async def test_status_and_geo_statuses_track_the_transport(): @pytest.mark.parametrize( ("geos", "expected_url"), [ - (("amer", "emea"), "https://amer.smba.trafficmanager.net/v3/websockets/connect"), - (("emea", "apac"), "https://emea.smba.trafficmanager.net/v3/websockets/connect"), - (("apac", "amer"), "https://apac.smba.trafficmanager.net/v3/websockets/connect"), + (("amer", "emea"), "https://amer.botapi.skype.com/v3/websockets/connect"), + (("emea", "apac"), "https://emea.botapi.skype.com/v3/websockets/connect"), + (("apac", "amer"), "https://apac.botapi.skype.com/v3/websockets/connect"), ], ) def test_negotiate_url_resolves_the_first_geo(geos: tuple[str, ...], expected_url: str): @@ -553,7 +553,7 @@ def test_app_socket_mode_options_are_honoured(): assert app.socket_mode is not None assert app.socket_mode.geo_list == ("amer",) - assert app.socket_mode.negotiate_url == "https://amer.smba.trafficmanager.net/v3/websockets/connect" + assert app.socket_mode.negotiate_url == "https://amer.botapi.skype.com/v3/websockets/connect" def test_app_rejects_socket_mode_with_a_custom_http_adapter(): diff --git a/packages/apps/tests/test_socket_mode_connection.py b/packages/apps/tests/test_socket_mode_connection.py index 56697927..2c521105 100644 --- a/packages/apps/tests/test_socket_mode_connection.py +++ b/packages/apps/tests/test_socket_mode_connection.py @@ -49,7 +49,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: if ( request.url.host == "botapi.example" or request.url.host.endswith(".botapi.example") - or request.url.host.endswith(".smba.trafficmanager.net") + or request.url.host.endswith(".botapi.skype.com") ): return httpx2.Response( 200, diff --git a/packages/apps/tests/test_socket_mode_transport.py b/packages/apps/tests/test_socket_mode_transport.py index 9cde5561..395796d9 100644 --- a/packages/apps/tests/test_socket_mode_transport.py +++ b/packages/apps/tests/test_socket_mode_transport.py @@ -215,9 +215,9 @@ async def test_transport_starts_all_geos_and_stops_every_connection(): assert transport.status == SocketModeStatus.READY expected_urls = [ - "https://amer.smba.trafficmanager.net/v3/websockets/connect", - "https://emea.smba.trafficmanager.net/v3/websockets/connect", - "https://apac.smba.trafficmanager.net/v3/websockets/connect", + "https://amer.botapi.skype.com/v3/websockets/connect", + "https://emea.botapi.skype.com/v3/websockets/connect", + "https://apac.botapi.skype.com/v3/websockets/connect", ] assert factory.urls == expected_urls await transport.stop() From d1646e9c7b2b95f629cc82f8cc2effa335339879 Mon Sep 17 00:00:00 2001 From: lilyydu <54044854+lilyydu@users.noreply.github.com> Date: Mon, 9 Nov 2026 15:27:52 -0800 Subject: [PATCH 08/10] relax geo requirement --- .../apps/src/microsoft_teams/apps/options.py | 4 +- .../apps/socket_mode/adapter.py | 6 +-- .../apps/socket_mode/transport.py | 28 +++++++------ .../apps/tests/test_socket_mode_adapter.py | 28 +++++++++++-- .../apps/tests/test_socket_mode_transport.py | 40 ++++++++++++++++--- 5 files changed, 78 insertions(+), 28 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py index 8fa543e2..737b0e2a 100644 --- a/packages/apps/src/microsoft_teams/apps/options.py +++ b/packages/apps/src/microsoft_teams/apps/options.py @@ -119,8 +119,8 @@ 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 select one or - more supported geo regions. The bot dials the Teams service, so no public endpoint + ``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. diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py b/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py index 32812278..e95edfb2 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py @@ -28,6 +28,7 @@ SocketModeTransport, SocketModeTransportOptions, build_negotiate_url, + resolve_geos, ) from .types import ( SOCKET_MODE_PROTOCOL_VERSION, @@ -186,13 +187,12 @@ 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) + return resolve_geos(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(geos[0] if geos else "") + return build_negotiate_url(self.geo_list[0]) def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: """ diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py index 92d1e76d..74d3847a 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py @@ -75,8 +75,7 @@ class SocketModeTransportOptions: 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. Supported values are - ``"amer"``, ``"emea"``, and ``"apac"``. + independently, so one geo dropping never affects the others. """ readiness_timeout: float = 30.0 @@ -186,14 +185,7 @@ 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") - unsupported_geos = tuple(geo for geo in geos if geo not in DEFAULT_GEOS) - if unsupported_geos: - raise ValueError(f"Socket Mode geos must be selected from {DEFAULT_GEOS}: {unsupported_geos!r}") + geos = resolve_geos(self._options.geos) negotiate_urls = tuple((geo, build_negotiate_url(geo)) for geo in geos) self._stop_event = asyncio.Event() @@ -342,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 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(set(configured_geos)) != len(configured_geos): + raise ValueError("Socket Mode geos must not contain duplicates") + return configured_geos + + def build_negotiate_url(geo: str) -> str: - # Only service-defined geos may be added to the negotiate hostname. - if geo not in DEFAULT_GEOS: - raise ValueError(f"Socket Mode geo must be one of {DEFAULT_GEOS}: {geo!r}") + 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}" diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index e290a449..805365b5 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -390,12 +390,32 @@ def test_negotiate_url_resolves_the_first_geo(geos: tuple[str, ...], expected_ur assert adapter.negotiate_url == expected_url -@pytest.mark.parametrize("geo", ["attacker.example#", "", "eur", "AMER"]) -def test_negotiate_url_rejects_unsupported_geo(geo: str): +@pytest.mark.parametrize("geo", ["eur", "AMER", "amer.amer"]) +def test_negotiate_url_uses_custom_geo(geo: str): adapter, _ = make_adapter(geos=(geo,)) - with pytest.raises(ValueError, match="Socket Mode geo must be one of"): - _ = adapter.negotiate_url + assert adapter.negotiate_url == f"https://{geo}.botapi.skype.com/v3/websockets/connect" + + +def test_empty_geo_sequence_uses_defaults(): + adapter, _ = make_adapter(geos=()) + + assert adapter.geo_list == ("amer", "emea", "apac") + assert adapter.negotiate_url == "https://amer.botapi.skype.com/v3/websockets/connect" + + +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 + + +def test_duplicate_geos_are_rejected(): + adapter, _ = make_adapter(geos=("amer", "amer")) + + 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): diff --git a/packages/apps/tests/test_socket_mode_transport.py b/packages/apps/tests/test_socket_mode_transport.py index 395796d9..67f754e5 100644 --- a/packages/apps/tests/test_socket_mode_transport.py +++ b/packages/apps/tests/test_socket_mode_transport.py @@ -226,24 +226,52 @@ async def test_transport_starts_all_geos_and_stops_every_connection(): @pytest.mark.asyncio -async def test_transport_rejects_empty_geos_before_connecting(): +async def test_transport_uses_default_geos_for_empty_sequence(): factory = MockConnectionFactory() transport = await make_transport(factory, geos=()) - with pytest.raises(ValueError, match="must contain at least one entry"): + await transport.start() + + assert transport.status == SocketModeStatus.READY + assert factory.urls == [ + "https://amer.botapi.skype.com/v3/websockets/connect", + "https://emea.botapi.skype.com/v3/websockets/connect", + "https://apac.botapi.skype.com/v3/websockets/connect", + ] + await transport.stop() + + +@pytest.mark.parametrize("geo", ["eur", "AMER", "amer.amer"]) +@pytest.mark.asyncio +async def test_transport_uses_custom_geo_values(geo: str): + factory = MockConnectionFactory() + transport = await make_transport(factory, geos=(geo,)) + + await transport.start() + + assert transport.status == SocketModeStatus.READY + assert factory.urls == [f"https://{geo}.botapi.skype.com/v3/websockets/connect"] + await transport.stop() + + +@pytest.mark.asyncio +async def test_transport_rejects_empty_string_before_connecting(): + factory = MockConnectionFactory() + transport = await make_transport(factory, geos=("amer", "")) + + with pytest.raises(ValueError, match="must not contain an empty string"): await transport.start() assert transport.status == SocketModeStatus.IDLE assert factory.connections == [] -@pytest.mark.parametrize("geo", ["attacker.example#", "", "eur", "AMER"]) @pytest.mark.asyncio -async def test_transport_rejects_unsupported_geo_before_connecting(geo: str): +async def test_transport_rejects_duplicate_geos_before_connecting(): factory = MockConnectionFactory() - transport = await make_transport(factory, geos=(geo,)) + transport = await make_transport(factory, geos=("amer", "amer")) - with pytest.raises(ValueError, match="must be selected from"): + with pytest.raises(ValueError, match="must not contain duplicates"): await transport.start() assert transport.status == SocketModeStatus.IDLE From e09e1d41a466c85b21a99a008a89ad2eac71e62b Mon Sep 17 00:00:00 2001 From: lilyydu <54044854+lilyydu@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:16:32 -0800 Subject: [PATCH 09/10] remove negotiate_url from adapter --- .../apps/socket_mode/adapter.py | 6 ----- .../apps/tests/test_socket_mode_adapter.py | 22 ------------------- 2 files changed, 28 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py b/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py index e95edfb2..5d0b16af 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/adapter.py @@ -27,7 +27,6 @@ DEFAULT_GEOS, SocketModeTransport, SocketModeTransportOptions, - build_negotiate_url, resolve_geos, ) from .types import ( @@ -189,11 +188,6 @@ def geo_list(self) -> tuple[str, ...]: """The geos this adapter connects to.""" return resolve_geos(self.options.geos) - @property - def negotiate_url(self) -> str: - """Resolved negotiate URL for the first geo. Each geo dials its own.""" - return build_negotiate_url(self.geo_list[0]) - def register_route(self, method: HttpMethod, path: str, handler: HttpRouteHandler) -> None: """ Accept the App's messaging route as a no-op -- inbound frames are dispatched diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index 805365b5..a6be4b4c 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -377,31 +377,10 @@ async def test_status_and_geo_statuses_track_the_transport(): assert adapter.status == SocketModeStatus.STOPPED -@pytest.mark.parametrize( - ("geos", "expected_url"), - [ - (("amer", "emea"), "https://amer.botapi.skype.com/v3/websockets/connect"), - (("emea", "apac"), "https://emea.botapi.skype.com/v3/websockets/connect"), - (("apac", "amer"), "https://apac.botapi.skype.com/v3/websockets/connect"), - ], -) -def test_negotiate_url_resolves_the_first_geo(geos: tuple[str, ...], expected_url: str): - adapter, _ = make_adapter(geos=geos) - assert adapter.negotiate_url == expected_url - - -@pytest.mark.parametrize("geo", ["eur", "AMER", "amer.amer"]) -def test_negotiate_url_uses_custom_geo(geo: str): - adapter, _ = make_adapter(geos=(geo,)) - - assert adapter.negotiate_url == f"https://{geo}.botapi.skype.com/v3/websockets/connect" - - def test_empty_geo_sequence_uses_defaults(): adapter, _ = make_adapter(geos=()) assert adapter.geo_list == ("amer", "emea", "apac") - assert adapter.negotiate_url == "https://amer.botapi.skype.com/v3/websockets/connect" def test_empty_string_geo_is_rejected(): @@ -573,7 +552,6 @@ def test_app_socket_mode_options_are_honoured(): assert app.socket_mode is not None assert app.socket_mode.geo_list == ("amer",) - assert app.socket_mode.negotiate_url == "https://amer.botapi.skype.com/v3/websockets/connect" def test_app_rejects_socket_mode_with_a_custom_http_adapter(): From 08e0bcfab056c4f72c25501156785f6334942aab Mon Sep 17 00:00:00 2001 From: lilyydu <54044854+lilyydu@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:02:18 -0800 Subject: [PATCH 10/10] fix casing --- .../apps/src/microsoft_teams/apps/socket_mode/transport.py | 2 +- packages/apps/tests/test_socket_mode_adapter.py | 5 +++-- packages/apps/tests/test_socket_mode_transport.py | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py index 74d3847a..fec33c7e 100644 --- a/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py +++ b/packages/apps/src/microsoft_teams/apps/socket_mode/transport.py @@ -340,7 +340,7 @@ def resolve_geos(geos: Sequence[str]) -> tuple[str, ...]: return DEFAULT_GEOS if "" in configured_geos: raise ValueError("Socket Mode geos must not contain an empty string") - if len(set(configured_geos)) != len(configured_geos): + if len({geo.lower() for geo in configured_geos}) != len(configured_geos): raise ValueError("Socket Mode geos must not contain duplicates") return configured_geos diff --git a/packages/apps/tests/test_socket_mode_adapter.py b/packages/apps/tests/test_socket_mode_adapter.py index a6be4b4c..e5657095 100644 --- a/packages/apps/tests/test_socket_mode_adapter.py +++ b/packages/apps/tests/test_socket_mode_adapter.py @@ -390,8 +390,9 @@ def test_empty_string_geo_is_rejected(): _ = adapter.geo_list -def test_duplicate_geos_are_rejected(): - adapter, _ = make_adapter(geos=("amer", "amer")) +@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 diff --git a/packages/apps/tests/test_socket_mode_transport.py b/packages/apps/tests/test_socket_mode_transport.py index 67f754e5..774c8489 100644 --- a/packages/apps/tests/test_socket_mode_transport.py +++ b/packages/apps/tests/test_socket_mode_transport.py @@ -267,9 +267,10 @@ async def test_transport_rejects_empty_string_before_connecting(): @pytest.mark.asyncio -async def test_transport_rejects_duplicate_geos_before_connecting(): +@pytest.mark.parametrize("geos", [("amer", "amer"), ("amer", "AMER")]) +async def test_transport_rejects_duplicate_geos_before_connecting(geos: tuple[str, ...]): factory = MockConnectionFactory() - transport = await make_transport(factory, geos=("amer", "amer")) + transport = await make_transport(factory, geos=geos) with pytest.raises(ValueError, match="must not contain duplicates"): await transport.start()