From fd3f0d5bb5f0278e7db0be21e46060d1e0b5fd92 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 18 Jun 2026 13:13:41 -0700 Subject: [PATCH 1/3] build(teams): add microsoft-teams-apps SDK to the teams extra (#93 PR 1 foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds microsoft-teams-apps/-api/-cards >=2.0.13 to the [teams] extra for the Teams adapter migration to the official MS SDK (mirrors upstream adapter-teams@4.30.0). Graph stays hand-rolled (no [graph] extra / msgraph-sdk). Verified the SDK's TokenValidator (apps/auth/token_validator.py) validates RS256 + audience(app_id + api:// variants) + issuer(api.botframework.com) via the Bot Framework JWKS — equivalent-or-stronger than the hand-rolled _verify_bot_framework_token block it will replace. --- pyproject.toml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index aed3748..6cb3425 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,14 @@ redis = ["redis>=5.0"] postgres = ["asyncpg>=0.29"] crypto = ["cryptography>=42.0"] discord = ["pynacl>=1.5", "aiohttp>=3.9"] -teams = ["aiohttp>=3.9"] +teams = [ + "aiohttp>=3.9", + # Official Microsoft Teams Apps Python SDK (issue #93 migration; mirrors upstream + # adapter-teams@4.30.0's @microsoft/teams.* deps). Graph stays hand-rolled (no [graph] extra). + "microsoft-teams-apps>=2.0.13", + "microsoft-teams-api>=2.0.13", + "microsoft-teams-cards>=2.0.13", +] telegram = ["aiohttp>=3.9"] whatsapp = ["aiohttp>=3.9"] messenger = ["aiohttp>=3.9"] From 8703c4dbd1cdbf361297c6d2a23c76c4d3eb7fec Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 18 Jun 2026 13:45:33 -0700 Subject: [PATCH 2/3] feat(teams): migrate inbound + auth to microsoft-teams-apps SDK (#93 PR 1/4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewire the Teams adapter's inbound webhook path and JWT authentication to delegate to the official microsoft-teams-apps Python SDK, mirroring upstream adapter-teams@chat@4.30.0. Outbound (post/edit/delete/typing), native streaming, and the Graph reader stay hand-rolled (PRs 2/3 + a separate decision). New: src/chat_sdk/adapters/teams/bridge.py - BridgeHttpAdapter implements the SDK HttpServerAdapter protocol. It captures the route handler the App registers during app.initialize(), exposes an async dispatch(request, options) for handle_webhook to call, and owns a per-activity WebhookOptions map (keyed by activity id, cleared in finally). Framework agnostic — body/header extraction duck-types across web frameworks; response shaping returns the {body, status, headers} dict consumers expect. Port of upstream bridge-adapter.ts. adapter.py: - Construct the SDK App from TeamsAdapterConfig via _to_app_options (port of config.ts toAppOptions): app_id/app_password/app_tenant_id/federated/app_type -> client_id/client_secret/tenant_id/managed_identity_client_id, with the same TEAMS_* env fallbacks. Inject the bridge; stamp client User-Agent "Vercel.ChatSDK". - initialize() registers SDK handlers (on_message/on_message_reaction/ on_card_action/on_dialog_open/on_dialog_submit/on_conversation_update/ on_install_add/remove), awaits app.initialize(), and overrides server.on_request with _dispatch_activity. - handle_webhook now delegates to bridge.dispatch. Inbound JWT validation is performed by the SDK's TokenValidator (RS256 + audience app_id/api:// variants + Bot Framework issuer/JWKS), enforced by default (skip_auth=False). Deleted the hand-rolled _verify_bot_framework_token / _jwks_client / openid-config fetch / BOT_FRAMEWORK_OPENID_CONFIG_URL and the now-unused request/response helpers (body/header extraction + response shaping live in the bridge). - _handle_teams_error (port of errors.ts handleTeamsError) now maps both the plain dicts the hand-rolled Graph/outbound path raises and the SDK's typed exceptions (status on inner_http_error.status_code / status_code / retry_after) onto AuthenticationError / AdapterPermissionError / AdapterRateLimitError / NetworkError. Divergence (SDK-forced, documented in docs/UPSTREAM_SYNC.md): the SDK's default on_request runs strict per-activity validation + a live token fetch before any handler. We keep the SDK as the auth + transport layer but route the already-authenticated lenient CoreActivity ourselves so minimal serverless payloads and the existing dict-based handler logic keep working unchanged. Tests: add tests/test_teams_bridge.py (protocol conformance, dispatch happy/ error paths, WebhookOptions lifecycle, body/header extraction). Rework the JWT-bypass fixtures to force the SDK's own skip_auth instead of patching the deleted method; add TestSdkInboundAuth asserting the SDK rejects unauthenticated / bad-token / unconfigured requests. Add _to_app_options and SDK-exception error-mapping tests. Point the shared body-extraction test at BridgeHttpAdapter._read_body. Public Adapter contract and create_teams_adapter(TeamsAdapterConfig(...)) unchanged. Gauntlet green: ruff/format/audit clean, pyrefly 0 errors, 4793 passed / 3 skipped. --- docs/UPSTREAM_SYNC.md | 2 + src/chat_sdk/adapters/teams/adapter.py | 456 ++++++++++++++++--------- src/chat_sdk/adapters/teams/bridge.py | 230 +++++++++++++ tests/test_fixture_replay.py | 28 +- tests/test_request_body_extraction.py | 5 +- tests/test_teams_adapter.py | 63 +++- tests/test_teams_bridge.py | 331 ++++++++++++++++++ tests/test_teams_coverage.py | 226 ++++++------ tests/test_teams_extended.py | 134 +++++++- 9 files changed, 1173 insertions(+), 302 deletions(-) create mode 100644 src/chat_sdk/adapters/teams/bridge.py create mode 100644 tests/test_teams_bridge.py diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 1414397..b3fd8c6 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -610,6 +610,8 @@ stay explicit instead of being rediscovered in code review. | Chat resolver | 3-level: explicit → ContextVar → global | Process-global singleton | See [DECISIONS.md](DECISIONS.md#why-3-level-chat-resolver) | | PostableObject history | Cached in message history with real message ID | Not cached (skips history) | Upstream gap — posted messages should appear in thread/channel history | | Teams `msteams` transport key | Stripped from action values | Not stripped | Upstream gap — SDK-injected metadata should not leak to handlers | +| Teams inbound activity routing (issue #93 PR 1) | `BridgeHttpAdapter.dispatch` feeds webhooks through the SDK `HttpServer` (JWT validation), but the adapter **overrides `app.server.on_request`** with `_dispatch_activity` instead of letting the SDK's default router run. Our callback dumps the lenient `CoreActivity` to a camelCase dict and routes by `type` to the existing handler logic. | Upstream `adapter-teams@4.30.0` registers `app.on("message" / "card.action" / …)` and lets `@microsoft/teams.apps` route via its typed dispatcher; handlers receive `ctx.activity` as a strongly-typed `IMessageActivity` etc. | The Python SDK's default `on_request` (`App._process_activity_event` → `ActivityProcessor.process_activity`) runs `ActivityTypeAdapter.validate_python` (strict per-activity validation — `recipient`/`id` required) **and** a live `api.users.token.get` network call inside `_build_context` before any handler fires. Minimal serverless webhook payloads (and the adapter's dict-based handler logic) can't survive strict validation, and the token fetch would make an unwanted outbound call per inbound activity. We keep the SDK as the **auth + transport** layer (JWT genuinely validated by its `TokenValidator`) but route the already-authenticated activity ourselves through the lenient `CoreActivity`, preserving the exact pre-migration handler behavior. The SDK `on_message`/`on_card_action`/… decorators are still registered for parity/forward-compat. Regression coverage: `tests/test_teams_extended.py::TestActivityTypes`, `tests/test_teams_coverage.py::TestSdkInboundAuth`, `tests/test_teams_bridge.py`. | +| Teams dialog/modal inbound (issue #93 PR 1) | `on_dialog_open` / `on_dialog_submit` are registered on the SDK App but only cache user context (no `process_modal_submit`, no task-module response) | Upstream `adapter-teams@4.30.0` `handleDialogOpen`/`handleDialogSubmit` drive `chat.processModalSubmit` + `modalToAdaptiveCard` | The pre-migration Python Teams adapter never implemented modal/dialog inbound processing, so PR 1 (inbound + auth plumbing) preserves that behavior rather than introducing new modal handling. Wiring dialogs to `chat.process_modal_submit` is tracked as a later wave of the #93 migration. | | Fallback streaming with whitespace-only streams (non-Teams adapters) | Placeholder cleared to `" "` on final edit | Placeholder left visible (`"..."` stuck) | Upstream 4.26 guards against empty edits but leaves the placeholder stranded on the message. We issue one final `edit_message(" ")` so the placeholder disappears when no real content was produced. Teams no longer routes through `_fallback_stream` after vercel/chat#416 (DMs use native streaming, group chats accumulate-and-post), so this divergence applies only to Slack / Discord / GitHub / Telegram / Google Chat / Linear / WhatsApp. | | Google Chat `` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `` as raw text (or parse the whole string as an autolink with a malformed URL) | Upstream 4.26 emits `` in `from_ast` but never taught the reverse direction to parse it. A message posted with `[label](url)` then read back through `fetch_messages` comes back as unstructured text (or worse, a link node with the full `url\|text` as its URL) in upstream. We close the round-trip via an AST placeholder substitution: each `` is extracted to a private-use sentinel, Markdown is parsed on the rest, and link nodes are injected where the sentinels landed. This avoids the Markdown parser's incomplete handling of balanced-parens link destinations, so URLs like `https://en.wikipedia.org/wiki/Foo_(bar)` round-trip intact. | | `from_json(data, adapter=X)` → `_adapter_name` | Updated to `X.name` so `to_json()` reflects the bound adapter | Kept at `json.adapterName`, so re-serialization can emit a name that no longer matches the actual adapter | Upstream TS has the same gap but only exposes it via the `fromJSON(json, adapter?)` overload. In Python we lean on this API more (explicit `chat=` / explicit `adapter=` is preferred over the singleton). We sync the name on rebind so runtime and serialize agree. | diff --git a/src/chat_sdk/adapters/teams/adapter.py b/src/chat_sdk/adapters/teams/adapter.py index 0316e4c..d7f6255 100644 --- a/src/chat_sdk/adapters/teams/adapter.py +++ b/src/chat_sdk/adapters/teams/adapter.py @@ -10,7 +10,6 @@ import asyncio import base64 -import inspect import json import os import re @@ -19,6 +18,7 @@ from typing import Any, Literal, NoReturn, cast from urllib.parse import quote, urlparse +from chat_sdk.adapters.teams.bridge import BridgeHttpAdapter from chat_sdk.adapters.teams.cards import card_to_adaptive_card from chat_sdk.adapters.teams.format_converter import TeamsFormatConverter from chat_sdk.adapters.teams.types import ( @@ -155,8 +155,66 @@ def text(self) -> str: re.compile(r"^https://smba\.infra\.(gcc|gov)\.teams\.microsoft\.(com|us)/"), ] -# Bot Framework OpenID configuration URL for JWT verification -BOT_FRAMEWORK_OPENID_CONFIG_URL = "https://login.botframework.com/v1/.well-known/openid-configuration" + +def _to_app_options(config: TeamsAdapterConfig) -> dict[str, Any]: + """Convert :class:`TeamsAdapterConfig` (public API) to Teams SDK ``AppOptions``. + + Python port of ``packages/adapter-teams/src/config.ts`` ``toAppOptions`` + (synced to ``adapter-teams@chat@4.30.0``). Maps our public + ``app_id``/``app_password``/``app_tenant_id``/``federated``/``app_type`` + fields onto the SDK's ``client_id``/``client_secret``/``tenant_id``/ + ``managed_identity_client_id`` keys, reading the same ``TEAMS_*`` env-var + fallbacks the legacy adapter used. + + Returns a dict suitable for ``App(**options)`` minus ``http_server_adapter`` + (the adapter injects the bridge separately). Only keys with a value are + included so the SDK applies its own defaults for the rest. + + Certificate auth is rejected here for exact parity with upstream — the + adapter constructor also guards it, but mirroring the check keeps the + config conversion self-contained. + """ + if config.certificate is not None: + raise ValidationError( + "teams", + "Certificate-based authentication is not yet supported by the Teams SDK adapter. " + "Use appPassword (client secret) or federated (workload identity) authentication instead.", + ) + + client_id = config.app_id if config.app_id is not None else os.environ.get("TEAMS_APP_ID") + # Federated (workload identity) auth derives the secret from a managed + # identity, so no client secret is supplied in that mode. + if config.federated is not None: + client_secret = None + elif config.app_password is not None: + client_secret = config.app_password + else: + client_secret = os.environ.get("TEAMS_APP_PASSWORD") + + # SingleTenant apps need a tenant_id; MultiTenant apps omit it. + if config.app_type == "MultiTenant": + tenant_id = None + elif config.app_tenant_id is not None: + tenant_id = config.app_tenant_id + else: + tenant_id = os.environ.get("TEAMS_APP_TENANT_ID") + + federated = config.federated + if federated is not None and federated.get("client_audience") and config.logger is not None: + config.logger.warn("federated.clientAudience is not supported by the Teams SDK and will be ignored.") + + managed_identity_client_id = federated.get("client_id") if federated is not None else None + + options: dict[str, Any] = {} + if client_id: + options["client_id"] = client_id + if client_secret: + options["client_secret"] = client_secret + if tenant_id: + options["tenant_id"] = tenant_id + if managed_identity_client_id: + options["managed_identity_client_id"] = managed_identity_client_id + return options def _validate_service_url(url: str) -> None: @@ -174,28 +232,64 @@ def _validate_service_url(url: str) -> None: ) +def _error_field(error: Any, dict_key: str, attr_name: str) -> Any: + """Read a field from a Teams error that may be a dict or an SDK exception. + + The hand-rolled Graph / outbound aiohttp path raises plain dicts + (``{"statusCode": 429, ...}``); the Microsoft Teams SDK raises typed + exceptions whose HTTP details live on attributes (``status_code``, + ``retry_after``, ``inner_http_error``). This reads ``dict_key`` from a + dict error or ``attr_name`` from an object error so :func:`_handle_teams_error` + maps both shapes to our taxonomy with one code path. + """ + if isinstance(error, dict): + return error.get(dict_key) + return getattr(error, attr_name, None) + + def _handle_teams_error(error: Any, operation: str) -> NoReturn: - """Convert Teams SDK errors to adapter errors and raise. + """Convert Teams SDK / Bot Framework errors to adapter errors and raise. + + Python port of ``packages/adapter-teams/src/errors.ts`` ``handleTeamsError`` + (synced to ``adapter-teams@chat@4.30.0``). Maps the error onto our taxonomy: - Raises an appropriate AdapterError subclass based on the error shape. + - ``401`` → :class:`AuthenticationError` + - ``403`` (or a "permission" message) → :class:`AdapterPermissionError` + - ``404`` → :class:`NetworkError` ("not found") + - ``429`` → :class:`AdapterRateLimitError` (with ``retry_after`` when present) + - any other message → :class:`NetworkError` + + Handles both the plain-dict errors raised by the still-hand-rolled Graph / + outbound path and the typed exceptions raised by the Microsoft Teams SDK + (whose HTTP status lives on ``inner_http_error.status_code`` / + ``status_code`` / ``status`` / ``code``). """ - if error and isinstance(error, dict): - inner_error = error.get("innerHttpError", {}) + if error is not None and isinstance(error, (dict, Exception)): + # SDK ``HttpError`` shape exposes the upstream status on + # ``inner_http_error.status_code``; dict errors use ``innerHttpError``. + inner = _error_field(error, "innerHttpError", "inner_http_error") + inner_status = _error_field(inner, "statusCode", "status_code") if inner is not None else None status_code = ( - inner_error.get("statusCode") or error.get("statusCode") or error.get("status") or error.get("code") + inner_status + if inner_status is not None + else ( + _error_field(error, "statusCode", "status_code") + or _error_field(error, "status", "status") + or _error_field(error, "code", "code") + ) ) if isinstance(status_code, str) and status_code.isdigit(): status_code = int(status_code) + message = error.get("message") if isinstance(error, dict) else str(error) + if status_code == 401: raise AuthenticationError( "teams", - f"Authentication failed for {operation}: {error.get('message', 'unauthorized')}", + f"Authentication failed for {operation}: {message or 'unauthorized'}", ) - if status_code == 403 or ( - isinstance(error.get("message"), str) and "permission" in error.get("message", "").lower() - ): + if status_code == 403 or (isinstance(message, str) and "permission" in message.lower()): raise AdapterPermissionError("teams", operation) if status_code == 404: raise NetworkError( @@ -203,12 +297,13 @@ def _handle_teams_error(error: Any, operation: str) -> NoReturn: f"Resource not found during {operation}: conversation or message may no longer exist", ) if status_code == 429: - retry_after = error.get("retryAfter") if isinstance(error.get("retryAfter"), (int, float)) else None + retry_after_raw = _error_field(error, "retryAfter", "retry_after") + retry_after = retry_after_raw if isinstance(retry_after_raw, (int, float)) else None raise AdapterRateLimitError("teams", retry_after) - if isinstance(error.get("message"), str): + if isinstance(message, str) and message and isinstance(error, dict): raise NetworkError( "teams", - f"Teams API error during {operation}: {error['message']}", + f"Teams API error during {operation}: {message}", ) if isinstance(error, Exception): @@ -264,7 +359,16 @@ def __init__(self, config: TeamsAdapterConfig | None = None) -> None: self._access_token: str | None = None self._token_expiry: float = 0 self._token_lock = asyncio.Lock() - self._jwks_client: Any | None = None # Cached PyJWKClient for JWT verification + + # Microsoft Teams SDK ``App`` — owns inbound JWT validation and + # activity routing (issue #93 PR 1). The ``BridgeHttpAdapter`` captures + # the route handler the App registers during ``app.initialize()`` so + # ``handle_webhook`` can dispatch serverless webhooks through it. The + # SDK is an optional ([teams] extra) dependency, so it is imported + # lazily here rather than at module scope. + self._bridge = BridgeHttpAdapter(self._logger) + self._app = self._build_app(config) + self._app_initialized = False # Shared aiohttp session for connection pooling self._http_session: Any | None = None @@ -292,6 +396,37 @@ def __init__(self, config: TeamsAdapterConfig | None = None) -> None: # AsyncMock so they don't actually wait the configured interval. self._stream_sleep_ms: Callable[[float], Awaitable[None]] = lambda ms: asyncio.sleep(ms / 1000.0) + def _build_app(self, config: TeamsAdapterConfig) -> Any: + """Construct the Microsoft Teams SDK ``App`` for this adapter. + + Lazy-imports ``microsoft_teams.apps`` (the optional ``[teams]`` extra), + maps :class:`TeamsAdapterConfig` onto SDK ``AppOptions`` via + :func:`_to_app_options`, injects the :class:`BridgeHttpAdapter`, and + stamps the ``User-Agent: Vercel.ChatSDK`` client header — matching + upstream ``adapter-teams/src/index.ts`` App construction. + + The SDK's ``App`` enforces inbound JWT validation by default + (``skip_auth`` defaults to ``False``); when ``client_id`` is configured + it builds a Bot Framework ``TokenValidator`` (RS256, audience = + ``app_id`` + ``api://`` variants, Bot Framework issuer + JWKS). We pass + no ``skip_auth`` so that default stands — replacing the previously + hand-rolled ``_verify_bot_framework_token`` block. + """ + try: + from microsoft_teams.apps import App + from microsoft_teams.common import ClientOptions + except ImportError as exc: # pragma: no cover - exercised via packaging + raise ImportError( + "The Teams adapter requires the 'teams' extra. Install it with: pip install 'chat-sdk[teams]'" + ) from exc + + options = _to_app_options(config) + return App( + **options, + client=ClientOptions(headers={"User-Agent": "Vercel.ChatSDK"}), + http_server_adapter=self._bridge, + ) + @property def name(self) -> str: return self._name @@ -313,10 +448,48 @@ def persist_message_history(self) -> bool | None: return None async def initialize(self, chat: ChatInstance) -> None: - """Initialize the adapter.""" + """Initialize the adapter and the underlying Teams SDK ``App``. + + Mirrors upstream ``TeamsAdapter.initialize`` (set ``chat`` → register + event handlers → ``await app.initialize()``). ``app.initialize()`` + registers the messaging-endpoint route with the + :class:`BridgeHttpAdapter` (so :meth:`handle_webhook` can dispatch) and + configures inbound JWT validation. We then point the SDK's + ``server.on_request`` at :meth:`_dispatch_activity` so JWT-validated + activities route to our chat-processing handlers. + """ self._chat = chat + self._register_event_handlers() + if not self._app_initialized: + await self._app.initialize() + # The SDK's ``HttpServer`` invokes ``on_request`` *after* JWT + # validation. The default callback runs the SDK's strict typed + # router + a live user-token fetch; we replace it with our own + # dispatcher that routes the (already-authenticated) activity to + # the registered handlers using the adapter's existing logic. + self._app.server.on_request = self._dispatch_activity + self._app_initialized = True self._logger.info("Teams adapter initialized") + def _register_event_handlers(self) -> None: + """Register inbound handlers on the Teams SDK ``App``. + + Registers the handlers via the SDK decorators so the App is aware of + them (parity with upstream ``registerEventHandlers``). The actual + dispatch happens through :meth:`_dispatch_activity` (wired in + :meth:`initialize`), which routes by activity type to the same handler + coroutines — keeping the chat-processing logic identical to the + pre-migration adapter while sourcing data from the SDK activity. + """ + self._app.on_message(self._on_sdk_message) + self._app.on_message_reaction(self._on_sdk_message_reaction) + self._app.on_card_action(self._on_sdk_card_action) + self._app.on_dialog_open(self._on_sdk_dialog_open) + self._app.on_dialog_submit(self._on_sdk_dialog_submit) + self._app.on_conversation_update(self._on_sdk_conversation_update) + self._app.on_install_add(self._on_sdk_install) + self._app.on_install_remove(self._on_sdk_install) + async def get_user(self, user_id: str) -> UserInfo | None: """Look up a Teams user via Microsoft Graph ``GET /users/{id}``. @@ -392,55 +565,135 @@ async def handle_webhook( request: Any, options: WebhookOptions | None = None, ) -> Any: - """Handle incoming webhook from Teams Bot Framework. + """Handle an incoming webhook from Teams Bot Framework. - Processes message, reaction, and card action activities. - """ - body = await self._get_request_body(request) - self._logger.debug("Teams webhook raw body", {"body": body[:500] if body else ""}) + Delegates to the :class:`BridgeHttpAdapter`, which feeds the request + through the Microsoft Teams SDK route handler: the SDK validates the + inbound JWT (RS256 + Bot Framework audience/issuer/JWKS) and routes the + activity to our handlers via :meth:`_dispatch_activity`. Returns the + framework-agnostic ``{body, status, headers}`` dict our consumers + expect. - # ---- JWT verification (Bot Framework tokens) ---- - if not self._app_id: - self._logger.warn("Rejecting Teams webhook: app_id is not configured, cannot verify JWT") - return self._make_response("Unauthorized – Teams app_id not configured", 401) + Mirrors upstream ``TeamsAdapter.handleWebhook`` (which is a one-line + delegate to ``bridgeAdapter.dispatch``). + """ + return await self._bridge.dispatch(request, options) - auth_result = await self._verify_bot_framework_token(request) - if auth_result is not None: - return auth_result + async def _dispatch_activity(self, event: Any) -> Any: + """Route a JWT-validated activity to the adapter's chat-processing logic. - try: - activity: dict[str, Any] = json.loads(body) - except (json.JSONDecodeError, ValueError): - self._logger.error("Failed to parse request body") - return self._make_response("Invalid JSON", 400) + Installed as the Teams SDK ``HttpServer.on_request`` callback in + :meth:`initialize`. By the time this runs the SDK has already validated + the inbound JWT. ``event.body`` is the SDK's lenient ``CoreActivity``; + we dump it back to the camelCase activity dict our handlers consume so + the routing logic stays identical to the pre-migration adapter while + the data now flows from the SDK-parsed activity. + Returns an ``InvokeResponse``-shaped dict the SDK's HttpServer maps to + the HTTP response. Card actions (``invoke``) return the Bot Framework + invoke acknowledgement; everything else returns ``200`` with no body. + """ + activity = self._activity_to_dict(event) activity_type = activity.get("type", "") self._logger.debug("Teams activity received", {"type": activity_type}) - # Cache user context from activity metadata + # Cache user context from activity metadata (serviceUrl / tenantId / + # aadObjectId / channel + DM context) — unchanged from upstream. await self._cache_user_context(activity) + options = self._bridge.get_webhook_options(activity.get("id")) + if activity_type == "message": await self._handle_message_activity(activity, options) elif activity_type == "messageReaction": self._handle_reaction_activity(activity, options) elif activity_type == "invoke": - # Adaptive card actions + # Adaptive card actions (Action.Execute → invoke). action_data = (activity.get("value") or {}).get("action", {}).get("data", {}) - if action_data.get("actionId"): + if isinstance(action_data, dict) and action_data.get("actionId"): await self._handle_adaptive_card_action(activity, action_data, options) - return self._make_json_response( - json.dumps( - { - "statusCode": 200, - "type": "application/vnd.microsoft.activity.message", - "value": "", - } - ), - 200, - ) + return { + "status": 200, + "body": { + "statusCode": 200, + "type": "application/vnd.microsoft.activity.message", + "value": "", + }, + } + + return {"status": 200, "body": None} + + @staticmethod + def _activity_to_dict(event: Any) -> dict[str, Any]: + """Extract the camelCase activity dict from a Teams SDK activity event. + + Handles both the SDK ``ActivityEvent`` (``event.body`` is a + ``CoreActivity`` pydantic model) and an ``ActivityContext`` (``ctx`` + whose ``ctx.activity`` is a typed activity model). In both cases we + ``model_dump(by_alias=True, exclude_none=True)`` to recover the exact + Bot Framework wire shape (``from``/``serviceUrl``/``channelData``/…) + that the adapter's dict-based handlers were written against. + """ + source = getattr(event, "body", None) + if source is None: + source = getattr(event, "activity", event) + model_dump = getattr(source, "model_dump", None) + if callable(model_dump): + dumped = model_dump(by_alias=True, exclude_none=True) + if isinstance(dumped, dict): + return dumped + return source if isinstance(source, dict) else {} + + # ------------------------------------------------------------------ + # Teams SDK event handlers (registered in _register_event_handlers). + # Each sources its activity from the SDK ``ActivityContext`` and routes to + # the adapter's existing chat-processing logic. They are wired to the SDK + # decorators for parity; dispatch is driven by _dispatch_activity. + # ------------------------------------------------------------------ + async def _on_sdk_message(self, ctx: Any) -> None: + activity = self._activity_to_dict(ctx) + await self._cache_user_context(activity) + await self._handle_message_activity(activity, self._bridge.get_webhook_options(activity.get("id"))) + + async def _on_sdk_message_reaction(self, ctx: Any) -> None: + activity = self._activity_to_dict(ctx) + await self._cache_user_context(activity) + self._handle_reaction_activity(activity, self._bridge.get_webhook_options(activity.get("id"))) + + async def _on_sdk_card_action(self, ctx: Any) -> Any: + activity = self._activity_to_dict(ctx) + await self._cache_user_context(activity) + action_data = (activity.get("value") or {}).get("action", {}).get("data", {}) + if isinstance(action_data, dict) and action_data.get("actionId"): + await self._handle_adaptive_card_action( + activity, action_data, self._bridge.get_webhook_options(activity.get("id")) + ) + return { + "status": 200, + "body": { + "statusCode": 200, + "type": "application/vnd.microsoft.activity.message", + "value": "", + }, + } + + async def _on_sdk_dialog_open(self, ctx: Any) -> Any: + # Modal/dialog support is out of PR-1 scope (tracked for a later wave); + # cache context for parity and return no task module response. + activity = self._activity_to_dict(ctx) + await self._cache_user_context(activity) + return None + + async def _on_sdk_dialog_submit(self, ctx: Any) -> Any: + activity = self._activity_to_dict(ctx) + await self._cache_user_context(activity) + return None + + async def _on_sdk_conversation_update(self, ctx: Any) -> None: + await self._cache_user_context(self._activity_to_dict(ctx)) - return self._make_json_response("{}", 200) + async def _on_sdk_install(self, ctx: Any) -> None: + await self._cache_user_context(self._activity_to_dict(ctx)) async def _cache_user_context(self, activity: dict[str, Any]) -> None: """Cache serviceUrl, tenantId, and channel context from activity metadata.""" @@ -2661,113 +2914,6 @@ async def _teams_delete( f"Teams API error: {response.status} {error_text}", ) - # ========================================================================= - # JWT verification (Bot Framework) - # ========================================================================= - - async def _verify_bot_framework_token(self, request: Any) -> Any | None: - """Verify the JWT Bearer token from the Bot Framework. - - Returns a 401 response dict if authentication fails, or ``None`` if - the token is valid. - """ - auth_header: str | None = self._get_header(request, "authorization") - if not auth_header or not auth_header.startswith("Bearer "): - self._logger.warn("Missing or invalid Authorization header on Teams webhook") - return self._make_response("Unauthorized", 401) - - token = auth_header[7:] - try: - import jwt as pyjwt - from jwt import PyJWKClient - - # Lazily create and cache the JWKS client - if self._jwks_client is None: - session = await self._get_http_session() - async with session.get(BOT_FRAMEWORK_OPENID_CONFIG_URL) as resp: - if resp.status != 200: - self._logger.error("Failed to fetch Bot Framework OpenID config", {"status": resp.status}) - return self._make_response("Unauthorized", 401) - openid_config = await resp.json() - jwks_uri = openid_config.get("jwks_uri") - if not jwks_uri: - self._logger.error("No jwks_uri in Bot Framework OpenID config") - return self._make_response("Unauthorized", 401) - self._jwks_client = PyJWKClient(jwks_uri) - - signing_key = await asyncio.to_thread(self._jwks_client.get_signing_key_from_jwt, token) - payload = pyjwt.decode( - token, - signing_key.key, - algorithms=["RS256"], - audience=self._app_id, - issuer="https://api.botframework.com", - ) - self._logger.debug( - "Teams JWT verified", - { - "iss": payload.get("iss"), - "aud": payload.get("aud"), - }, - ) - return None # success - except Exception as exc: - self._logger.warn(f"Teams JWT verification failed: {exc}") - return self._make_response("Unauthorized", 401) - - # ========================================================================= - # Request/Response helpers (framework-agnostic) - # ========================================================================= - - @staticmethod - async def _get_request_body(request: Any) -> str: - """Extract the request body as a string.""" - # `hasattr` narrows `Any` → `object` (not awaitable); using - # `getattr(..., None)` preserves `Any` for framework duck-typing. - # Handle both callable and non-callable `request.text`. Gating - # entry on callability would drop populated string attributes. - text_attr = getattr(request, "text", None) - if text_attr is not None: - if callable(text_attr): - result = text_attr() - text_attr = await result if inspect.isawaitable(result) else result - return text_attr.decode("utf-8") if isinstance(text_attr, (bytes, bytearray)) else str(text_attr) - body = getattr(request, "body", None) - if body is not None: - if callable(body): - body = body() - # Some frameworks expose `body` as an async method; if calling it - # produced a coroutine, await it before treating as bytes/str. - if inspect.isawaitable(body): - body = await body - if hasattr(body, "read"): - raw_result = body.read() - raw = await raw_result if inspect.isawaitable(raw_result) else raw_result - return raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw) - return body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else str(body) - data = getattr(request, "data", None) - if data is not None: - return data.decode("utf-8") if isinstance(data, (bytes, bytearray)) else str(data) - return "" - - def _get_header(self, request: Any, name: str) -> str | None: - """Extract a header value from the request.""" - if hasattr(request, "headers"): - headers = request.headers - if isinstance(headers, dict): - return headers.get(name) or headers.get(name.title()) - if hasattr(headers, "get"): - return headers.get(name) - return None - - def _make_response(self, body: str, status: int) -> Any: - """Create a simple text response.""" - return {"body": body, "status": status, "headers": {"Content-Type": "text/plain"}} - - def _make_json_response(self, body: str, status: int) -> Any: - """Create a JSON response.""" - return {"body": body, "status": status, "headers": {"Content-Type": "application/json"}} - def create_teams_adapter(config: TeamsAdapterConfig | None = None) -> TeamsAdapter: """Factory function to create a Teams adapter.""" diff --git a/src/chat_sdk/adapters/teams/bridge.py b/src/chat_sdk/adapters/teams/bridge.py new file mode 100644 index 0000000..bfa51ac --- /dev/null +++ b/src/chat_sdk/adapters/teams/bridge.py @@ -0,0 +1,230 @@ +"""Bridge between framework-agnostic webhooks and the Microsoft Teams SDK. + +Python port of ``packages/adapter-teams/src/bridge-adapter.ts`` (synced to +upstream ``adapter-teams@chat@4.30.0``). + +The :class:`BridgeHttpAdapter` implements the Teams SDK's +:class:`~microsoft_teams.apps.http.adapter.HttpServerAdapter` protocol. Rather +than owning an HTTP server (the SDK's default ``FastAPIAdapter`` does), it +captures the single route handler the ``App`` registers during +``app.initialize()`` and exposes :meth:`dispatch` for the adapter's +``handle_webhook`` to call. This keeps the adapter serverless / framework +agnostic: the consumer's web framework calls ``handle_webhook`` → +``bridge.dispatch`` → the SDK route handler (which performs JWT validation and +activity routing) → back out as a plain ``{body, status, headers}`` dict. + +It also owns the per-activity :class:`~chat_sdk.types.WebhookOptions` map so each +inbound event handler can recover the ``WebhookOptions`` (e.g. ``wait_until``) +that belong to *its* activity without sharing mutable state across concurrent +webhooks. Options are keyed by the activity ``id`` for the duration of a single +dispatch and removed in the ``finally`` block. +""" + +from __future__ import annotations + +import inspect +import json +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Literal, cast + +from chat_sdk.logger import Logger +from chat_sdk.types import WebhookOptions + +if TYPE_CHECKING: + # Reuse the SDK's own ``HttpRouteHandler`` protocol so ``register_route`` + # matches the ``HttpServerAdapter`` interface signature exactly. + from microsoft_teams.apps.http.adapter import HttpRouteHandler + + +class BridgeHttpAdapter: + """Virtual ``HttpServerAdapter`` that captures the SDK route handler. + + Implements the Teams SDK ``HttpServerAdapter`` protocol + (``register_route`` / ``serve_static`` / ``start`` / ``stop``). Only + ``register_route`` does real work — it stashes the handler the ``App`` + registers for the messaging endpoint so :meth:`dispatch` can invoke it. + + Mirrors upstream ``BridgeHttpAdapter`` in + ``packages/adapter-teams/src/bridge-adapter.ts``. + """ + + def __init__(self, logger: Logger) -> None: + self._handler: HttpRouteHandler | None = None + self._webhook_options: dict[str, WebhookOptions] = {} + self._logger = logger + + # ------------------------------------------------------------------ + # HttpServerAdapter protocol + # ------------------------------------------------------------------ + def register_route(self, method: Literal["POST"], path: str, handler: HttpRouteHandler) -> None: + """Capture the route handler registered by ``app.initialize()``. + + The SDK registers exactly one ``POST`` route (the messaging + endpoint). We ignore the method/path and keep the handler — the + consumer's web framework decides which URL maps to + ``handle_webhook``. Parameter names mirror the SDK's + ``HttpServerAdapter`` protocol for keyword compatibility. + """ + del method, path + self._handler = handler + + def serve_static(self, path: str, directory: str) -> None: + """No-op — the bridge never serves static assets (tabs/pages).""" + del path, directory + + async def start(self, port: int) -> None: + """The bridge does not own a server lifecycle; starting is a no-op. + + Unlike the SDK's default adapter (which raises here), the Teams + adapter intentionally never calls ``app.start()`` — it only uses + ``app.initialize()`` for proactive messaging + route capture, and + drives inbound traffic through :meth:`dispatch`. Returning quietly + keeps that contract explicit. + """ + + async def stop(self) -> None: + """No-op — nothing to tear down (we never started a server).""" + + # ------------------------------------------------------------------ + # Dispatch + # ------------------------------------------------------------------ + async def dispatch(self, request: Any, options: WebhookOptions | None = None) -> dict[str, Any]: + """Dispatch a framework-agnostic webhook request through the SDK. + + Extracts the raw body + headers from ``request`` (duck-typed across + web frameworks), parses the JSON activity, records ``options`` keyed + by the activity ``id``, then invokes the captured SDK route handler. + The SDK handler performs JWT validation and activity routing; its + ``{status, body}`` result is translated back into the + ``{body, status, headers}`` dict our consumers expect. + """ + body = await self._read_body(request) + self._logger.debug("Teams webhook raw body", {"body": body[:500] if body else ""}) + + try: + parsed_body: Any = json.loads(body) if body else {} + except (json.JSONDecodeError, ValueError) as exc: + self._logger.error("Failed to parse request body", {"error": str(exc)}) + return _make_response("Invalid JSON", 400, content_type="text/plain") + + if not isinstance(parsed_body, dict): + self._logger.error("Teams webhook body is not a JSON object") + return _make_response("Invalid JSON", 400, content_type="text/plain") + + if self._handler is None: + self._logger.error("No SDK route handler registered (app not initialized?)") + return _make_response( + json.dumps({"error": "No handler registered"}), + 500, + content_type="application/json", + ) + + headers = self._read_headers(request) + + activity_id = parsed_body.get("id") + if activity_id and options is not None: + self._webhook_options[activity_id] = options + + try: + server_response = await self._handler({"body": parsed_body, "headers": headers}) + status = server_response.get("status", 200) + response_body = server_response.get("body") + if response_body is not None: + return _make_response( + json.dumps(response_body), + status, + content_type="application/json", + ) + return _make_response("", status, content_type=None) + except Exception as error: # pragma: no cover - defensive parity with upstream + self._logger.error("Bridge adapter dispatch error", {"error": str(error)}) + return _make_response( + json.dumps({"error": "Internal error"}), + 500, + content_type="application/json", + ) + finally: + if activity_id: + self._webhook_options.pop(activity_id, None) + + def get_webhook_options(self, activity_id: str | None) -> WebhookOptions | None: + """Recover the ``WebhookOptions`` recorded for ``activity_id``. + + Called by each inbound event handler so the chat-processing call + uses the options (e.g. ``wait_until``) that belong to its own + activity. Returns ``None`` when there is no id or no recorded + options (e.g. a fire-and-forget webhook with no options passed). + """ + if not activity_id: + return None + return self._webhook_options.get(activity_id) + + # ------------------------------------------------------------------ + # Request introspection (framework-agnostic) + # ------------------------------------------------------------------ + @staticmethod + async def _read_body(request: Any) -> str: + """Extract the request body as a string across web frameworks. + + Handles ``request.text`` (callable or attribute, sync or async), + ``request.body`` (callable/awaitable/stream), and ``request.data``. + Mirrors the duck-typing the adapter relied on before the migration so + existing consumers and test doubles keep working. + """ + text_attr = getattr(request, "text", None) + if text_attr is not None: + if callable(text_attr): + result = text_attr() + text_attr = await result if inspect.isawaitable(result) else result + if isinstance(text_attr, (bytes, bytearray)): + return text_attr.decode("utf-8") + return str(text_attr) + + body = getattr(request, "body", None) + if body is not None: + if callable(body): + body = body() + if inspect.isawaitable(body): + body = await body + if hasattr(body, "read"): + raw_result = body.read() + raw = await raw_result if inspect.isawaitable(raw_result) else raw_result + return raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else str(raw) + return body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else str(body) + + data = getattr(request, "data", None) + if data is not None: + return data.decode("utf-8") if isinstance(data, (bytes, bytearray)) else str(data) + return "" + + @staticmethod + def _read_headers(request: Any) -> dict[str, str]: + """Extract request headers as a plain ``dict[str, str]``. + + The SDK's JWT validator reads the ``Authorization`` header from this + dict, so we normalise whatever header container the framework exposes + (``dict``, ``Mapping``, or an iterable of pairs) into a plain dict. + """ + headers = getattr(request, "headers", None) + if headers is None: + return {} + if isinstance(headers, dict): + return {str(k): str(v) for k, v in headers.items()} + # Starlette/aiohttp-style multidicts and ``email.message`` headers all + # support ``.items()``; fall back to that before giving up. + items = getattr(headers, "items", None) + if callable(items): + try: + pairs = cast("Iterable[tuple[Any, Any]]", items()) + except Exception: + return {} + return {str(key): str(value) for key, value in pairs} + return {} + + +def _make_response(body: str, status: int, *, content_type: str | None) -> dict[str, Any]: + """Build the framework-agnostic response dict our consumers expect.""" + response_headers: dict[str, str] = {} + if content_type is not None: + response_headers["Content-Type"] = content_type + return {"body": body, "status": status, "headers": response_headers} diff --git a/tests/test_fixture_replay.py b/tests/test_fixture_replay.py index 842fc33..3b28bdf 100644 --- a/tests/test_fixture_replay.py +++ b/tests/test_fixture_replay.py @@ -123,6 +123,26 @@ def _teams_request(body: str) -> _FakeRequest: ) +def _teams_skip_auth(): + """Force the Microsoft Teams SDK to skip inbound JWT validation. + + Inbound auth moved into the SDK ``App`` (issue #93 PR 1). Fixture replays + carry no signed Bot Framework token, so this patches ``HttpServer.initialize`` + to enable ``skip_auth`` — exercising the real bridge → SDK → handler path + without signature checks. Returns a context manager covering both + ``adapter.initialize()`` (where the route + validator are set up) and the + subsequent ``handle_webhook`` dispatch. + """ + from microsoft_teams.apps.http.http_server import HttpServer + + real_initialize = HttpServer.initialize + + def _initialize_skip_auth(self, credentials=None, skip_auth=False, cloud=None): + return real_initialize(self, credentials=credentials, skip_auth=True, cloud=cloud) + + return patch.object(HttpServer, "initialize", _initialize_skip_auth) + + def _gchat_request(body: str) -> _FakeRequest: """Build a Google Chat request (JWT verification skipped when no project number).""" return _FakeRequest(body, {"content-type": "application/json"}) @@ -367,12 +387,16 @@ async def _send_and_assert_message( """Send a fixture payload and assert process_message was called.""" adapter = self._make_adapter(fixture) mock_chat = _make_mock_chat() - await adapter.initialize(mock_chat) body = json.dumps(fixture[payload_key]) request = _teams_request(body) - with patch.object(adapter, "_verify_bot_framework_token", new_callable=AsyncMock, return_value=None): + # Inbound JWT validation now runs inside the Microsoft Teams SDK + # (issue #93 PR 1). Fixture payloads carry no signed token, so we + # force the SDK's own ``skip_auth`` flag while still driving the real + # bridge → SDK → handler dispatch path. + with _teams_skip_auth(): + await adapter.initialize(mock_chat) result = await adapter.handle_webhook(request) assert result["status"] == 200 diff --git a/tests/test_request_body_extraction.py b/tests/test_request_body_extraction.py index c8c0454..9511ecd 100644 --- a/tests/test_request_body_extraction.py +++ b/tests/test_request_body_extraction.py @@ -132,7 +132,7 @@ def _adapters() -> list[tuple[str, Any]]: from chat_sdk.adapters.discord.adapter import DiscordAdapter from chat_sdk.adapters.github.adapter import GitHubAdapter from chat_sdk.adapters.linear.adapter import LinearAdapter - from chat_sdk.adapters.teams.adapter import TeamsAdapter + from chat_sdk.adapters.teams.bridge import BridgeHttpAdapter from chat_sdk.adapters.telegram.adapter import TelegramAdapter from chat_sdk.adapters.whatsapp.adapter import WhatsAppAdapter @@ -142,7 +142,8 @@ def _adapters() -> list[tuple[str, Any]]: ("whatsapp", WhatsAppAdapter._get_request_body), ("discord", DiscordAdapter._get_request_body), ("linear", LinearAdapter._get_request_body), - ("teams", TeamsAdapter._get_request_body), + # Teams' body extraction moved into the SDK dispatch bridge (#93 PR 1). + ("teams", BridgeHttpAdapter._read_body), ] diff --git a/tests/test_teams_adapter.py b/tests/test_teams_adapter.py index 1c115f5..b483b3d 100644 --- a/tests/test_teams_adapter.py +++ b/tests/test_teams_adapter.py @@ -601,16 +601,29 @@ def data(self) -> bytes: class TestHandleWebhook: @pytest.fixture(autouse=True) def _skip_jwt(self, monkeypatch): - """Bypass JWT verification in unit tests.""" - monkeypatch.setattr( - TeamsAdapter, - "_verify_bot_framework_token", - AsyncMock(return_value=None), - ) + """Bypass inbound JWT validation in unit tests. + + Inbound auth now runs inside the Microsoft Teams SDK ``App`` + (issue #93 PR 1); ``handle_webhook`` dispatches through the + ``BridgeHttpAdapter`` into the SDK's ``HttpServer``. We force the SDK's + ``skip_auth`` flag so unsigned test requests still reach the bridge's + JSON parsing without needing a real Bot Framework token. + """ + from microsoft_teams.apps.http.http_server import HttpServer + + real_initialize = HttpServer.initialize + + def _initialize_skip_auth(self, credentials=None, skip_auth=False, cloud=None): + return real_initialize(self, credentials=credentials, skip_auth=True, cloud=cloud) + + monkeypatch.setattr(HttpServer, "initialize", _initialize_skip_auth) @pytest.mark.asyncio async def test_400_for_invalid_json(self): adapter = _make_adapter(logger=_make_logger()) + chat = MagicMock() + chat.get_state = MagicMock(return_value=MagicMock(set=AsyncMock(), get=AsyncMock(return_value=None))) + await adapter.initialize(chat) request = _FakeRequest("not valid json{{{", {"content-type": "application/json"}) response = await adapter.handle_webhook(request) @@ -630,6 +643,44 @@ async def test_stores_chat_instance(self): await adapter.initialize(mock_chat) assert adapter.name == "teams" + @pytest.mark.asyncio + async def test_initialize_wires_sdk_app_and_bridge(self): + adapter = _make_adapter() + await adapter.initialize(MagicMock()) + # The SDK App captured the messaging-endpoint route in our bridge so + # handle_webhook can dispatch through it. + assert adapter._bridge._handler is not None + # JWT/auth + activity routing now flow through our dispatcher. + on_request = adapter._app.server.on_request + assert on_request is not None + assert on_request.__func__ is TeamsAdapter._dispatch_activity + assert on_request.__self__ is adapter + + @pytest.mark.asyncio + async def test_initialize_is_idempotent(self): + adapter = _make_adapter() + chat = MagicMock() + await adapter.initialize(chat) + first_handler = adapter._bridge._handler + # Re-initializing (e.g. adapter reused across chats) must not double-init + # the SDK App or lose the captured route handler. + await adapter.initialize(chat) + assert adapter._bridge._handler is first_handler + + +class TestSdkAppConstruction: + def test_app_built_with_vercel_user_agent(self): + adapter = _make_adapter() + # The User-Agent the adapter stamps onto the SDK client must identify + # the Chat SDK (parity with upstream App construction). The SDK merges + # its own UA on top, so we assert against the client options we passed. + client_opts = adapter._app.options.client + assert client_opts.headers["User-Agent"] == "Vercel.ChatSDK" + + def test_app_id_mapped_to_sdk_client_id(self): + adapter = _make_adapter(app_id="my-bot-id") + assert adapter._app.id == "my-bot-id" + # --------------------------------------------------------------------------- # renderFormatted diff --git a/tests/test_teams_bridge.py b/tests/test_teams_bridge.py new file mode 100644 index 0000000..330dfda --- /dev/null +++ b/tests/test_teams_bridge.py @@ -0,0 +1,331 @@ +"""Tests for ``BridgeHttpAdapter`` — the serverless dispatch bridge between +framework-agnostic webhooks and the Microsoft Teams SDK. + +Python port coverage for ``packages/adapter-teams/src/bridge-adapter.ts`` +(issue #93 PR 1). The bridge: + +- implements the SDK ``HttpServerAdapter`` protocol (``register_route`` capture), +- parses the request body + headers across web frameworks, +- records per-activity ``WebhookOptions`` for the duration of a dispatch, +- invokes the captured SDK route handler and translates its ``{status, body}`` + result back into the ``{body, status, headers}`` dict consumers expect. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from microsoft_teams.apps.http.adapter import HttpServerAdapter + +from chat_sdk.adapters.teams.bridge import BridgeHttpAdapter +from chat_sdk.logger import ConsoleLogger +from chat_sdk.types import WebhookOptions + + +def _make_bridge() -> BridgeHttpAdapter: + return BridgeHttpAdapter(ConsoleLogger("error", prefix="teams")) + + +class _FakeRequest: + """Minimal request double exposing ``text()`` + ``headers``.""" + + def __init__(self, body: str, headers: dict[str, str] | None = None): + self._body = body + self.headers = headers or {} + + async def text(self) -> str: + return self._body + + +# --------------------------------------------------------------------------- +# Protocol conformance + route capture +# --------------------------------------------------------------------------- + + +class TestProtocol: + def test_satisfies_sdk_http_server_adapter_protocol(self): + bridge = _make_bridge() + assert isinstance(bridge, HttpServerAdapter) + + def test_register_route_captures_handler(self): + bridge = _make_bridge() + + async def handler(_request): + return {"status": 200, "body": None} + + bridge.register_route("POST", "/api/messages", handler) + assert bridge._handler is handler + + def test_serve_static_is_noop(self): + bridge = _make_bridge() + # Should not raise. + bridge.serve_static("/tabs/x", "/tmp/x") + + async def test_start_and_stop_are_noops(self): + bridge = _make_bridge() + await bridge.start(3978) + await bridge.stop() + + +# --------------------------------------------------------------------------- +# dispatch — happy paths +# --------------------------------------------------------------------------- + + +class TestDispatch: + async def test_dispatch_calls_handler_with_parsed_body_and_headers(self): + bridge = _make_bridge() + captured: dict[str, Any] = {} + + async def handler(request): + captured["request"] = request + return {"status": 200, "body": None} + + bridge.register_route("POST", "/api/messages", handler) + req = _FakeRequest('{"type": "message", "id": "m1"}', {"Authorization": "Bearer x"}) + + result = await bridge.dispatch(req) + + assert captured["request"]["body"] == {"type": "message", "id": "m1"} + assert captured["request"]["headers"]["Authorization"] == "Bearer x" + assert result["status"] == 200 + # No body → empty string + no Content-Type header. + assert result["body"] == "" + assert result["headers"] == {} + + async def test_dispatch_serializes_handler_body_as_json(self): + bridge = _make_bridge() + + async def handler(_request): + return {"status": 200, "body": {"statusCode": 200, "value": ""}} + + bridge.register_route("POST", "/api/messages", handler) + result = await bridge.dispatch(_FakeRequest('{"id": "i1", "type": "invoke"}')) + + assert result["status"] == 200 + assert result["body"] == '{"statusCode": 200, "value": ""}' + assert result["headers"]["Content-Type"] == "application/json" + + async def test_dispatch_passes_through_handler_status(self): + bridge = _make_bridge() + + async def handler(_request): + return {"status": 401, "body": {"error": "Unauthorized"}} + + bridge.register_route("POST", "/api/messages", handler) + result = await bridge.dispatch(_FakeRequest('{"id": "m1"}')) + assert result["status"] == 401 + assert result["body"] == '{"error": "Unauthorized"}' + + async def test_dispatch_invalid_json_returns_400_without_calling_handler(self): + bridge = _make_bridge() + handler = AsyncMock(return_value={"status": 200, "body": None}) + bridge.register_route("POST", "/api/messages", handler) + + result = await bridge.dispatch(_FakeRequest("not json{{{")) + + assert result["status"] == 400 + assert result["body"] == "Invalid JSON" + assert result["headers"]["Content-Type"] == "text/plain" + handler.assert_not_called() + + async def test_dispatch_non_object_json_returns_400(self): + bridge = _make_bridge() + handler = AsyncMock(return_value={"status": 200, "body": None}) + bridge.register_route("POST", "/api/messages", handler) + + result = await bridge.dispatch(_FakeRequest("[1, 2, 3]")) + + assert result["status"] == 400 + handler.assert_not_called() + + async def test_dispatch_without_handler_returns_500(self): + bridge = _make_bridge() + result = await bridge.dispatch(_FakeRequest('{"id": "m1"}')) + assert result["status"] == 500 + assert "No handler registered" in result["body"] + + async def test_empty_body_parses_to_object_and_calls_handler(self): + # An empty body parses to ``{}`` (a dict), so the handler is invoked. + bridge = _make_bridge() + handler = AsyncMock(return_value={"status": 200, "body": None}) + bridge.register_route("POST", "/api/messages", handler) + + result = await bridge.dispatch(_FakeRequest("")) + assert result["status"] == 200 + handler.assert_awaited_once() + assert handler.await_args.args[0]["body"] == {} + + async def test_dispatch_handler_exception_returns_500(self): + bridge = _make_bridge() + + async def handler(_request): + raise RuntimeError("boom") + + bridge.register_route("POST", "/api/messages", handler) + result = await bridge.dispatch(_FakeRequest('{"id": "m1"}')) + assert result["status"] == 500 + assert "Internal error" in result["body"] + + +# --------------------------------------------------------------------------- +# WebhookOptions per-activity map +# --------------------------------------------------------------------------- + + +class TestWebhookOptions: + async def test_options_available_during_dispatch_and_cleared_after(self): + bridge = _make_bridge() + seen: dict[str, Any] = {} + options = WebhookOptions(wait_until=lambda _t: None) + + async def handler(request): + activity_id = request["body"]["id"] + seen["during"] = bridge.get_webhook_options(activity_id) + return {"status": 200, "body": None} + + bridge.register_route("POST", "/api/messages", handler) + await bridge.dispatch(_FakeRequest('{"id": "act-1"}'), options) + + # Visible to the handler while the activity is in flight... + assert seen["during"] is options + # ...and removed afterward so it cannot leak to a later activity. + assert bridge.get_webhook_options("act-1") is None + + async def test_options_cleared_even_when_handler_raises(self): + bridge = _make_bridge() + + async def handler(_request): + raise RuntimeError("boom") + + bridge.register_route("POST", "/api/messages", handler) + await bridge.dispatch(_FakeRequest('{"id": "act-2"}'), WebhookOptions()) + assert bridge.get_webhook_options("act-2") is None + + async def test_no_options_recorded_when_none_passed(self): + bridge = _make_bridge() + seen: dict[str, Any] = {} + + async def handler(request): + seen["during"] = bridge.get_webhook_options(request["body"]["id"]) + return {"status": 200, "body": None} + + bridge.register_route("POST", "/api/messages", handler) + await bridge.dispatch(_FakeRequest('{"id": "act-3"}')) + assert seen["during"] is None + + def test_get_webhook_options_none_id_returns_none(self): + bridge = _make_bridge() + assert bridge.get_webhook_options(None) is None + + +# --------------------------------------------------------------------------- +# Body + header extraction across frameworks +# --------------------------------------------------------------------------- + + +class TestBodyExtraction: + async def test_async_text_method(self): + bridge = _make_bridge() + assert await bridge._read_body(_FakeRequest('{"a": 1}')) == '{"a": 1}' + + async def test_static_text_attribute(self): + bridge = _make_bridge() + + class Req: + text = "static-text" + + assert await bridge._read_body(Req()) == "static-text" + + async def test_bytes_body_attribute(self): + bridge = _make_bridge() + + class Req: + body = b"raw-bytes" + + assert await bridge._read_body(Req()) == "raw-bytes" + + async def test_async_body_callable(self): + bridge = _make_bridge() + + class Req: + body = AsyncMock(return_value=b"async-body") + + assert await bridge._read_body(Req()) == "async-body" + + async def test_body_with_read_method(self): + bridge = _make_bridge() + + class Stream: + def read(self): + return b"stream-bytes" + + class Req: + body = Stream() + + assert await bridge._read_body(Req()) == "stream-bytes" + + async def test_data_attribute_fallback(self): + bridge = _make_bridge() + + class Req: + data = b"data-bytes" + + assert await bridge._read_body(Req()) == "data-bytes" + + async def test_empty_request_returns_empty_string(self): + bridge = _make_bridge() + + class Req: + pass + + assert await bridge._read_body(Req()) == "" + + +class TestHeaderExtraction: + def test_dict_headers(self): + bridge = _make_bridge() + + class Req: + headers = {"Authorization": "Bearer t", "X-Other": "v"} + + out = bridge._read_headers(Req()) + assert out == {"Authorization": "Bearer t", "X-Other": "v"} + + def test_mapping_headers_via_items(self): + bridge = _make_bridge() + + class Headers: + def items(self): + return [("authorization", "Bearer abc")] + + class Req: + headers = Headers() + + out = bridge._read_headers(Req()) + assert out == {"authorization": "Bearer abc"} + + def test_missing_headers_returns_empty(self): + bridge = _make_bridge() + + class Req: + pass + + assert bridge._read_headers(Req()) == {} + + +# --------------------------------------------------------------------------- +# Logger interaction (debug log of raw body) +# --------------------------------------------------------------------------- + + +class TestLogging: + async def test_logs_raw_body_at_debug(self): + logger = MagicMock(debug=MagicMock(), error=MagicMock()) + bridge = BridgeHttpAdapter(logger) + handler = AsyncMock(return_value={"status": 200, "body": None}) + bridge.register_route("POST", "/api/messages", handler) + + await bridge.dispatch(_FakeRequest('{"id": "m1"}')) + assert logger.debug.called diff --git a/tests/test_teams_coverage.py b/tests/test_teams_coverage.py index 37fdddd..d71724a 100644 --- a/tests/test_teams_coverage.py +++ b/tests/test_teams_coverage.py @@ -8,7 +8,7 @@ - _get_access_token (token endpoint call) - _get_graph_token - _validate_service_url (allowed/disallowed patterns) -- _verify_bot_framework_token (JWT verification with mock JWKS) +- inbound JWT validation enforced by the Microsoft Teams SDK (TestSdkInboundAuth) - postMessage with Adaptive Card - editMessage - deleteMessage @@ -44,12 +44,23 @@ @pytest.fixture(autouse=True) def _skip_teams_jwt(monkeypatch): - """Bypass JWT verification in unit tests.""" - monkeypatch.setattr( - TeamsAdapter, - "_verify_bot_framework_token", - AsyncMock(return_value=None), - ) + """Bypass inbound JWT validation in unit tests (no real Bot Framework tokens). + + Inbound auth now lives in the Microsoft Teams SDK ``App`` (issue #93 PR 1): + the ``BridgeHttpAdapter`` dispatches webhooks through the SDK's + ``HttpServer``, which validates the Bearer token. We force the SDK's own + ``skip_auth`` flag on so the bridge → SDK → handler path runs without a + signed token. ``TestSdkInboundAuth`` asserts the SDK rejects + unauthenticated requests when this bypass is absent. + """ + from microsoft_teams.apps.http.http_server import HttpServer + + real_initialize = HttpServer.initialize + + def _initialize_skip_auth(self, credentials=None, skip_auth=False, cloud=None): + return real_initialize(self, credentials=credentials, skip_auth=True, cloud=cloud) + + monkeypatch.setattr(HttpServer, "initialize", _initialize_skip_auth) def _make_adapter(**overrides) -> TeamsAdapter: @@ -587,19 +598,27 @@ async def test_caches_channel_context(self): # --------------------------------------------------------------------------- -# _verify_bot_framework_token (without autouse fixture) +# Inbound JWT validation is now enforced by the Microsoft Teams SDK +# (issue #93 PR 1). These tests run WITHOUT the skip-auth bypass to assert the +# SDK actually rejects unauthenticated / unconfigured webhooks. # --------------------------------------------------------------------------- -class TestVerifyBotFrameworkToken: +class TestSdkInboundAuth: + @pytest.fixture(autouse=True) + def _skip_teams_jwt(self): + """Override the module-level skip-auth bypass — these tests need the + SDK's real JWT enforcement so they can assert it rejects requests.""" + yield + async def test_webhook_rejects_when_no_app_id(self): - """When app_id is empty, webhook should reject.""" + """No credentials configured: the SDK rejects every inbound request.""" adapter = TeamsAdapter(TeamsAdapterConfig(app_id="", app_password="test")) chat = _make_mock_chat() await adapter.initialize(chat) class FakeReq: - headers = {} + headers: dict[str, str] = {} async def text(self): return "{}" @@ -611,6 +630,68 @@ def data(self): response = await adapter.handle_webhook(FakeReq()) assert response["status"] == 401 + async def test_webhook_rejects_missing_bearer_token(self): + """Credentials configured but no Bearer token: the SDK responds 401.""" + adapter = TeamsAdapter(TeamsAdapterConfig(app_id="test-app-id", app_password="secret")) + chat = _make_mock_chat() + await adapter.initialize(chat) + + activity = { + "type": "message", + "id": "m1", + "from": {"id": "u1"}, + "conversation": {"id": "19:abc@thread.tacv2"}, + "serviceUrl": "https://smba.trafficmanager.net/teams/", + } + + class FakeReq: + headers: dict[str, str] = {} + + def __init__(self) -> None: + self._body = json.dumps(activity) + + async def text(self): + return self._body + + @property + def data(self): + return self._body.encode("utf-8") + + response = await adapter.handle_webhook(FakeReq()) + assert response["status"] == 401 + # The SDK validated auth before reaching any chat handler. + assert not chat.process_message.called + + async def test_webhook_rejects_invalid_bearer_token(self): + """A malformed Bearer token fails SDK signature validation → 401.""" + adapter = TeamsAdapter(TeamsAdapterConfig(app_id="test-app-id", app_password="secret")) + chat = _make_mock_chat() + await adapter.initialize(chat) + + activity = { + "type": "message", + "id": "m1", + "from": {"id": "u1"}, + "conversation": {"id": "19:abc@thread.tacv2"}, + "serviceUrl": "https://smba.trafficmanager.net/teams/", + } + + class FakeReq: + def __init__(self) -> None: + self._body = json.dumps(activity) + self.headers = {"Authorization": "Bearer not.a.real.jwt"} + + async def text(self): + return self._body + + @property + def data(self): + return self._body.encode("utf-8") + + response = await adapter.handle_webhook(FakeReq()) + assert response["status"] == 401 + assert not chat.process_message.called + # --------------------------------------------------------------------------- # postMessage / editMessage / deleteMessage / startTyping via HTTP @@ -1146,126 +1227,9 @@ def test_extract_card_title_body_not_list(self): assert adapter._extract_card_title({"body": "not a list"}) is None -# --------------------------------------------------------------------------- -# _get_request_body edge cases -# --------------------------------------------------------------------------- - - -class TestGetRequestBody: - async def test_body_callable_with_read(self): - adapter = _make_adapter(logger=_make_logger()) - - class FakeReq: - class body: - @staticmethod - def read(): - return b"hello" - - body = body() # noqa: E731 - body.read = staticmethod(lambda: b"hello") - - class SimpleReq: - body = b"raw bytes" - - result = await adapter._get_request_body(SimpleReq()) - assert result == "raw bytes" - - async def test_text_callable(self): - adapter = _make_adapter(logger=_make_logger()) - - class FakeReq: - async def text(self): - return "text content" - - result = await adapter._get_request_body(FakeReq()) - assert result == "text content" - - async def test_text_attribute(self): - adapter = _make_adapter(logger=_make_logger()) - - class FakeReq: - text = "static text" - - result = await adapter._get_request_body(FakeReq()) - assert result == "static text" - - async def test_data_attribute(self): - adapter = _make_adapter(logger=_make_logger()) - - class FakeReq: - data = b"byte data" - - result = await adapter._get_request_body(FakeReq()) - assert result == "byte data" - - async def test_empty_request(self): - adapter = _make_adapter(logger=_make_logger()) - - class FakeReq: - pass - - result = await adapter._get_request_body(FakeReq()) - assert result == "" - - -# --------------------------------------------------------------------------- -# _get_header edge cases -# --------------------------------------------------------------------------- - - -class TestGetHeader: - def test_dict_headers_title_case(self): - adapter = _make_adapter(logger=_make_logger()) - - class FakeReq: - headers = {"Authorization": "Bearer token"} - - result = adapter._get_header(FakeReq(), "authorization") - # dict.get falls back to title-case key "Authorization" - assert result == "Bearer token" - - def test_no_headers_attribute(self): - adapter = _make_adapter(logger=_make_logger()) - - class FakeReq: - pass - - assert adapter._get_header(FakeReq(), "x-test") is None - - def test_headers_with_get_method(self): - adapter = _make_adapter(logger=_make_logger()) - - class FakeHeaders: - def get(self, name): - if name == "authorization": - return "Bearer abc" - return None - - class FakeReq: - headers = FakeHeaders() - - result = adapter._get_header(FakeReq(), "authorization") - assert result == "Bearer abc" - - -# --------------------------------------------------------------------------- -# _make_response / _make_json_response -# --------------------------------------------------------------------------- - - -class TestMakeResponses: - def test_make_response(self): - adapter = _make_adapter() - r = adapter._make_response("OK", 200) - assert r["body"] == "OK" - assert r["status"] == 200 - assert r["headers"]["Content-Type"] == "text/plain" - - def test_make_json_response(self): - adapter = _make_adapter() - r = adapter._make_json_response('{"ok":true}', 200) - assert r["body"] == '{"ok":true}' - assert r["headers"]["Content-Type"] == "application/json" +# Request-body / header extraction and response shaping moved to +# ``BridgeHttpAdapter`` (issue #93 PR 1); those paths are covered by +# tests/test_teams_bridge.py. # --------------------------------------------------------------------------- diff --git a/tests/test_teams_extended.py b/tests/test_teams_extended.py index a40a423..6dc143b 100644 --- a/tests/test_teams_extended.py +++ b/tests/test_teams_extended.py @@ -27,6 +27,7 @@ MESSAGEID_STRIP_PATTERN, TeamsAdapter, _handle_teams_error, + _to_app_options, ) from chat_sdk.adapters.teams.types import ( TeamsAdapterConfig, @@ -48,12 +49,24 @@ @pytest.fixture(autouse=True) def _skip_teams_jwt(monkeypatch): - """Bypass JWT verification in unit tests (no real Bot Framework tokens).""" - monkeypatch.setattr( - TeamsAdapter, - "_verify_bot_framework_token", - AsyncMock(return_value=None), - ) + """Bypass inbound JWT validation in unit tests (no real Bot Framework tokens). + + Inbound auth now lives in the Microsoft Teams SDK ``App`` (issue #93 PR 1): + the ``BridgeHttpAdapter`` dispatches webhooks through the SDK's + ``HttpServer``, which validates the Bearer token via its ``TokenValidator``. + Unit tests don't carry signed tokens, so we force the SDK's own + ``skip_auth`` flag on — exercising the real bridge → SDK → handler dispatch + path while bypassing signature checks. The dedicated auth tests assert the + SDK *does* reject unauthenticated requests when this is not applied. + """ + from microsoft_teams.apps.http.http_server import HttpServer + + real_initialize = HttpServer.initialize + + def _initialize_skip_auth(self, credentials=None, skip_auth=False, cloud=None): + return real_initialize(self, credentials=credentials, skip_auth=True, cloud=cloud) + + monkeypatch.setattr(HttpServer, "initialize", _initialize_skip_auth) def _make_adapter(**overrides) -> TeamsAdapter: @@ -391,6 +404,115 @@ def test_inner_http_error_401(self): ) +class _FakeSdkHttpError(Exception): + """Stand-in for a Microsoft Teams SDK ``HttpError`` (status on attributes).""" + + def __init__(self, message="sdk error", status_code=None, retry_after=None, inner_http_error=None): + super().__init__(message) + self.status_code = status_code + self.retry_after = retry_after + self.inner_http_error = inner_http_error + + +class TestHandleTeamsErrorSdkExceptions: + """``_handle_teams_error`` must map SDK exception objects (status on + attributes), not just the plain dicts the hand-rolled Graph path raises.""" + + def test_sdk_exception_401_maps_to_auth_error(self): + with pytest.raises(AuthenticationError): + _handle_teams_error(_FakeSdkHttpError("nope", status_code=401), "postMessage") + + def test_sdk_exception_403_maps_to_permission_error(self): + with pytest.raises(AdapterPermissionError): + _handle_teams_error(_FakeSdkHttpError("forbidden", status_code=403), "postMessage") + + def test_sdk_exception_404_maps_to_network_error(self): + with pytest.raises(NetworkError, match="not found"): + _handle_teams_error(_FakeSdkHttpError("missing", status_code=404), "postMessage") + + def test_sdk_exception_429_carries_retry_after(self): + with pytest.raises(AdapterRateLimitError) as exc_info: + _handle_teams_error(_FakeSdkHttpError("slow down", status_code=429, retry_after=12), "postMessage") + assert exc_info.value.retry_after == 12 + + def test_sdk_exception_inner_http_error_status(self): + inner = _FakeSdkHttpError("inner", status_code=401) + with pytest.raises(AuthenticationError): + _handle_teams_error(_FakeSdkHttpError("outer", inner_http_error=inner), "postMessage") + + def test_sdk_exception_permission_keyword_in_message(self): + with pytest.raises(AdapterPermissionError): + _handle_teams_error(_FakeSdkHttpError("Permission required for resource"), "postMessage") + + def test_sdk_exception_without_status_falls_back_to_network_error(self): + with pytest.raises(NetworkError, match="generic sdk failure"): + _handle_teams_error(_FakeSdkHttpError("generic sdk failure"), "postMessage") + + +# --------------------------------------------------------------------------- +# Config conversion (toAppOptions port) +# --------------------------------------------------------------------------- + + +class TestToAppOptions: + def test_client_secret_auth(self): + opts = _to_app_options(TeamsAdapterConfig(app_id="app-1", app_password="secret-1", app_tenant_id="tenant-1")) + assert opts["client_id"] == "app-1" + assert opts["client_secret"] == "secret-1" + assert opts["tenant_id"] == "tenant-1" + assert "managed_identity_client_id" not in opts + + def test_multitenant_omits_tenant_id(self): + opts = _to_app_options( + TeamsAdapterConfig( + app_id="app-1", app_password="secret-1", app_tenant_id="tenant-1", app_type="MultiTenant" + ) + ) + assert "tenant_id" not in opts + + def test_federated_omits_secret_and_sets_managed_identity(self): + opts = _to_app_options( + TeamsAdapterConfig( + app_id="app-1", + app_password="should-be-ignored", + app_tenant_id="tenant-1", + federated={"client_id": "mi-client-1"}, + ) + ) + assert "client_secret" not in opts + assert opts["managed_identity_client_id"] == "mi-client-1" + + def test_federated_client_audience_logs_warning(self): + logger = MagicMock(warn=MagicMock()) + _to_app_options( + TeamsAdapterConfig( + app_id="app-1", + app_tenant_id="tenant-1", + federated={"client_id": "mi-1", "client_audience": "api://AzureADTokenExchange"}, + logger=logger, + ) + ) + assert logger.warn.called + + def test_env_var_fallbacks(self, monkeypatch): + monkeypatch.setenv("TEAMS_APP_ID", "env-app") + monkeypatch.setenv("TEAMS_APP_PASSWORD", "env-secret") + monkeypatch.setenv("TEAMS_APP_TENANT_ID", "env-tenant") + opts = _to_app_options(TeamsAdapterConfig()) + assert opts["client_id"] == "env-app" + assert opts["client_secret"] == "env-secret" + assert opts["tenant_id"] == "env-tenant" + + def test_certificate_rejected(self): + with pytest.raises(ValidationError): + _to_app_options( + TeamsAdapterConfig( + app_id="app-1", + certificate=TeamsAuthCertificate(certificate_private_key="key"), + ) + ) + + # --------------------------------------------------------------------------- # Message ID patterns # --------------------------------------------------------------------------- From 2c674eca0adb69adf447ec7c1fd3b1d4165cce65 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 18 Jun 2026 14:00:24 -0700 Subject: [PATCH 3/3] build(teams): install Teams SDK in dev group + all extra; pyrefly any-list (#143 CI fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-1's Teams SDK deps were only in the [teams] extra, but CI runs 'uv sync --group dev' — so the SDK was absent at test time and test_teams_bridge.py's module-level import aborted collection of the whole 4793-test suite. Adds microsoft-teams-apps/-api/-cards to the dev group and the all extra, plus pyrefly replace-imports-with-any. Verified: full collection 4796 tests (no abort), teams suites 187 passed. --- pyproject.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6cb3425..5a17630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,9 @@ all = [ "pynacl>=1.5", "aiohttp>=3.9", "google-auth>=2.0", + "microsoft-teams-apps>=2.0.13", + "microsoft-teams-api>=2.0.13", + "microsoft-teams-cards>=2.0.13", ] [build-system] @@ -130,6 +133,11 @@ dev = [ "pyjwt[crypto]>=2.8", "ruff>=0.4.0", "pyrefly==0.61.1", + # Teams adapter SDK (issue #93) — in dev so CI (`uv sync --group dev`) installs it + # and the Teams test suite runs; matches how the other optional adapter deps are wired. + "microsoft-teams-apps>=2.0.13", + "microsoft-teams-api>=2.0.13", + "microsoft-teams-cards>=2.0.13", ] # --------------------------------------------------------------------------- @@ -173,6 +181,9 @@ replace-imports-with-any = [ # GitHub App auth "jwt", "jwt.*", + # Teams — official MS Teams Apps SDK (optional teams extra) + "microsoft_teams", + "microsoft_teams.*", ] [tool.pyrefly.errors]