Skip to content

feat(teams): migrate inbound + auth to microsoft-teams-apps SDK (#93 PR 1/4)#143

Merged
patrick-chinchill merged 3 commits into
mainfrom
feat/teams-sdk-pr1-plumbing
Jun 18, 2026
Merged

feat(teams): migrate inbound + auth to microsoft-teams-apps SDK (#93 PR 1/4)#143
patrick-chinchill merged 3 commits into
mainfrom
feat/teams-sdk-pr1-plumbing

Conversation

@patrick-chinchill

@patrick-chinchill patrick-chinchill commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

What & why (issue #93, PR 1 of 4)

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 — those are PRs 2/3 and a separate decision.

The public Adapter contract and create_teams_adapter(TeamsAdapterConfig(...)) are unchanged (chinchill depends on this). All public config field names are untouched.

Inbound + auth flow after this PR

consumer web framework
  → TeamsAdapter.handle_webhook(request, options)
    → BridgeHttpAdapter.dispatch(request, options)   # parse body, record per-activity WebhookOptions
      → SDK HttpServer.handle_request                # JWT validation (TokenValidator)
        → App.server.on_request = _dispatch_activity # our override
          → existing _handle_message_activity / _handle_reaction_activity /
            _handle_adaptive_card_action + _cache_user_context
            → chat.process_message / process_reaction / process_action

Inbound JWT validation is now performed by the SDK's TokenValidator (RS256, audience = app_id + api://{app_id} + api://botid-{app_id}, Bot Framework issuer + JWKS), enforced by default (skip_auth=False). The hand-rolled _verify_bot_framework_token / _jwks_client / openid-config fetch / BOT_FRAMEWORK_OPENID_CONFIG_URL are deleted.

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 async dispatch(request, options), and owns a per-activity WebhookOptions map (keyed by activity id, cleared in finally). Framework-agnostic: body/header extraction duck-types across web frameworks; responses come back as the {body, status, headers} dict consumers expect. Port of upstream bridge-adapter.ts.

adapter.py changes

  • _to_app_options (port of config.ts toAppOptions): maps app_id/app_password/app_tenant_id/federated/app_type → SDK client_id/client_secret/tenant_id/managed_identity_client_id, with the same TEAMS_* env fallbacks.
  • App constructed with the bridge injected + 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(), overrides server.on_request.
  • _handle_teams_error (port of errors.ts handleTeamsError) maps both the hand-rolled Graph path's plain dicts and the SDK's typed exceptions onto our taxonomy (Authentication / Permission / RateLimit / Network).

Divergence (SDK-forced — documented in docs/UPSTREAM_SYNC.md)

The Python SDK's default on_request runs strict per-activity validation (ActivityTypeAdapter, recipient/id required) and a live users.token.get network call inside _build_context before any handler fires. Minimal serverless payloads + the adapter's dict-based handlers can't survive that. So we keep the SDK as the auth + transport layer (JWT genuinely validated) but route the already-authenticated lenient CoreActivity ourselves, preserving the exact pre-migration handler behavior. Dialog/modal inbound is registered for parity but not yet wired to process_modal_submit (matches the pre-migration Python adapter; later wave of #93).

Tests

  • New tests/test_teams_bridge.py (27 tests): protocol conformance, dispatch happy/error paths, WebhookOptions lifecycle, body/header extraction.
  • JWT-bypass fixtures now force the SDK's own skip_auth (not the deleted method). New TestSdkInboundAuth asserts the SDK rejects unauthenticated / bad-token / unconfigured requests.
  • Added _to_app_options + SDK-exception error-mapping tests; shared body-extraction test points at BridgeHttpAdapter._read_body.

Gauntlet

ruff ✓ · ruff format --check ✓ · audit_test_quality.py 0 hard failures ✓ · pyrefly 0 errors ✓ · pytest 4793 passed, 3 skipped.

Summary by CodeRabbit

Release Notes

  • New Features

    • Teams adapter now leverages Microsoft Teams SDK for webhook routing and inbound activity handling
  • Bug Fixes

    • Enhanced error handling for Teams, Graph, and Bot Framework failures with improved status code mapping
  • Dependencies

    • Added Microsoft Teams Apps SDK packages to optional and development dependencies
  • Documentation

    • Updated documentation noting known differences from TypeScript SDK implementation

… 1 foundation)

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.
…PR 1/4)

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.
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 4e8df17e-1c38-4634-8a30-f38e3f82c681

📥 Commits

Reviewing files that changed from the base of the PR and between 576ecbf and 2c674ec.

📒 Files selected for processing (10)
  • docs/UPSTREAM_SYNC.md
  • pyproject.toml
  • src/chat_sdk/adapters/teams/adapter.py
  • src/chat_sdk/adapters/teams/bridge.py
  • tests/test_fixture_replay.py
  • tests/test_request_body_extraction.py
  • tests/test_teams_adapter.py
  • tests/test_teams_bridge.py
  • tests/test_teams_coverage.py
  • tests/test_teams_extended.py

📝 Walkthrough

Walkthrough

The Teams adapter is migrated from hand-rolled Bot Framework JWT verification and manual activity parsing to the Microsoft Teams Apps SDK. A new BridgeHttpAdapter captures the SDK's route handler and provides a serverless dispatch() path. TeamsAdapter now builds a SDK App, delegates webhook auth to HttpServer.initialize, and routes activities via _dispatch_activity. All test suites are updated to bypass JWT validation via SDK patching.

Changes

Teams SDK Integration via BridgeHttpAdapter

Layer / File(s) Summary
Dependency and type-check configuration
pyproject.toml
teams, all, and dev optional-dependency groups gain microsoft-teams-apps, microsoft-teams-api, microsoft-teams-cards; pyrefly config treats microsoft_teams imports as Any.
BridgeHttpAdapter: SDK route capture and dispatch
src/chat_sdk/adapters/teams/bridge.py
New class captures the Teams SDK's registered POST handler via register_route, exposes dispatch() to read/parse requests, manage per-activity WebhookOptions lifecycle, invoke the SDK handler, and return the adapter's {body, status, headers} response dict; includes _read_body, _read_headers, and _make_response helpers.
_to_app_options and _handle_teams_error refactor
src/chat_sdk/adapters/teams/adapter.py
_to_app_options() translates TeamsAdapterConfig to SDK AppOptions with env-var fallbacks, certificate-auth rejection, and managed-identity support; _handle_teams_error() is rewritten with _error_field() to normalize dict and SDK-exception error shapes into typed adapter errors.
TeamsAdapter init, SDK wiring, and dispatch
src/chat_sdk/adapters/teams/adapter.py
__init__ creates BridgeHttpAdapter and builds the SDK App via _build_app() (lazy import, User-Agent: Vercel.ChatSDK); initialize() registers SDK event handlers, calls app.initialize() once, and overrides server.on_request with _dispatch_activity; handle_webhook() delegates to bridge.dispatch(); _on_sdk_* handlers cache context and forward to existing processing methods; prior _verify_bot_framework_token and manual request helpers are removed.
BridgeHttpAdapter test suite
tests/test_teams_bridge.py
New test file covering protocol conformance, dispatch happy/error paths, per-activity WebhookOptions lifecycle, _read_body duck-typing across all request patterns, _read_headers normalization, and debug logging.
Adapter and extended test updates
tests/test_teams_adapter.py, tests/test_teams_extended.py, tests/test_request_body_extraction.py
_skip_jwt fixture patched to use HttpServer.initialize(skip_auth=True); new tests for bridge handler capture, server.on_request wiring, SDK App User-Agent/client-id construction; TestHandleTeamsErrorSdkExceptions and TestToAppOptions added; body extraction test updated to BridgeHttpAdapter._read_body.
Coverage and fixture-replay test updates
tests/test_teams_coverage.py, tests/test_fixture_replay.py
Autouse fixtures updated to patch SDK auth; TestSdkInboundAuth added with 401 rejection tests for missing/invalid Authorization; _teams_skip_auth() helper added to fixture replay; removed test sections moved to test_teams_bridge.py.
Upstream sync non-parity docs
docs/UPSTREAM_SYNC.md
Two rows added to known-non-parity table: Teams inbound activity routing override and absence of modal-submit processing.

Sequence Diagram(s)

sequenceDiagram
  participant Client as Inbound Webhook
  participant TeamsAdapter
  participant BridgeHttpAdapter
  participant TeamsSDKApp as Teams SDK App
  participant TeamsSDKServer as SDK HttpServer
  participant ActivityRouter as _dispatch_activity

  Client->>TeamsAdapter: handle_webhook(request, options)
  TeamsAdapter->>BridgeHttpAdapter: dispatch(request, options)
  BridgeHttpAdapter->>BridgeHttpAdapter: _read_body / _read_headers
  BridgeHttpAdapter->>BridgeHttpAdapter: JSON parse → store WebhookOptions[activity.id]
  BridgeHttpAdapter->>TeamsSDKServer: captured_handler({ body, headers })
  TeamsSDKServer->>TeamsSDKServer: JWT validation (HttpServer.initialize)
  TeamsSDKServer->>ActivityRouter: on_request(event)
  ActivityRouter->>ActivityRouter: _activity_to_dict → route by type
  ActivityRouter-->>TeamsSDKServer: processed
  TeamsSDKServer-->>BridgeHttpAdapter: { status, body }
  BridgeHttpAdapter->>BridgeHttpAdapter: clear WebhookOptions (finally)
  BridgeHttpAdapter-->>TeamsAdapter: { body, status, headers }
  TeamsAdapter-->>Client: HTTP response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • Chinchill-AI/chat-sdk-python#65: Overlaps with this PR's certificate-based auth rejection path in _to_app_options(), which rejects config.certificate and raises ValidationError, mirroring the same startup guard updated in that PR.

Poem

🐇 Hoppity-hop through the webhook maze,
The old JWT checker deserves some praise—
But now the SDK takes the auth baton,
BridgeHttpAdapter bridges it on!
No more hand-rolled token spin,
The Teams SDK handles it within. 🎉

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.


import inspect
import json
from collections.abc import Iterable

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request integrates the official Microsoft Teams Apps Python SDK into the Teams adapter, replacing hand-rolled JWT verification and request body extraction with the SDK's App and a custom BridgeHttpAdapter. Feedback on these changes suggests returning an empty JSON object instead of None for unhandled invoke activities to prevent client timeouts, passing the adapter's logger to _to_app_options with explicit is not None checks to ensure configuration warnings are logged, and adding a fallback to .dict() for Pydantic v1 compatibility during activity extraction.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines 610 to +622
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": "",
},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In the Bot Framework protocol, invoke activities are synchronous and expect a valid JSON response. If an invoke activity is received but does not match the expected actionId check, returning an empty body (None) can cause errors or timeout warnings in the Teams client. Returning an empty JSON object ({}) is the standard way to safely acknowledge unhandled invoke activities.

Suggested change
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": "",
},
}
elif activity_type == "invoke":
# Adaptive card actions (Action.Execute → invoke).
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, options)
return {
"status": 200,
"body": {
"statusCode": 200,
"type": "application/vnd.microsoft.activity.message",
"value": "",
},
}
return {"status": 200, "body": {}}

# 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]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The _to_app_options function is a module-level helper that only has access to config. If the user does not provide a custom logger in TeamsAdapterConfig, config.logger is None, and the warning about client_audience being ignored will never be logged. Passing the adapter's initialized logger (which defaults to ConsoleLogger) as an optional parameter ensures this warning is always visible.

Suggested change
def _to_app_options(config: TeamsAdapterConfig) -> dict[str, Any]:
def _to_app_options(config: TeamsAdapterConfig, logger: Logger | None = None) -> dict[str, Any]:

Comment on lines +202 to +204
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.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the passed-in logger parameter (falling back to config.logger) to log the warning about client_audience being ignored. Use is not None to check for the presence of client_audience to avoid silently ignoring falsy but valid values (like an empty string).

Suggested change
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.")
federated = config.federated
if federated is not None and federated.get("client_audience") is not None:
log = logger or config.logger
if log is not None:
log.warn("federated.clientAudience is not supported by the Teams SDK and will be ignored.")
References
  1. When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use is not None instead of a truthiness check to avoid silently ignoring them.

"The Teams adapter requires the 'teams' extra. Install it with: pip install 'chat-sdk[teams]'"
) from exc

options = _to_app_options(config)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Pass the adapter's initialized self._logger to _to_app_options so that any configuration warnings are properly logged even if no custom logger was provided in TeamsAdapterConfig.

Suggested change
options = _to_app_options(config)
options = _to_app_options(config, self._logger)

Comment on lines +640 to +644
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure compatibility with both Pydantic v1 and v2 (since some environments or older SDK versions might still use Pydantic v1), we should fall back to the .dict() method if .model_dump() is not available on the source object.

Suggested change
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
model_dump = getattr(source, "model_dump", None)
if not callable(model_dump):
model_dump = getattr(source, "dict", None)
if callable(model_dump):
dumped = model_dump(by_alias=True, exclude_none=True)
if isinstance(dumped, dict):
return dumped

…-list (#143 CI fix)

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.
@patrick-chinchill patrick-chinchill marked this pull request as ready for review June 18, 2026 21:03
@patrick-chinchill patrick-chinchill merged commit 2676f0b into main Jun 18, 2026
8 checks passed
@patrick-chinchill patrick-chinchill deleted the feat/teams-sdk-pr1-plumbing branch June 19, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant