Skip to content

Commit 2676f0b

Browse files
feat(teams): migrate inbound + auth to microsoft-teams-apps SDK (#93 PR 1/4) (#143)
* build(teams): add microsoft-teams-apps SDK to the teams extra (#93 PR 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. * feat(teams): migrate inbound + auth to microsoft-teams-apps SDK (#93 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. * build(teams): install Teams SDK in dev group + all extra; pyrefly any-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.
1 parent 576ecbf commit 2676f0b

10 files changed

Lines changed: 1192 additions & 303 deletions

docs/UPSTREAM_SYNC.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,8 @@ stay explicit instead of being rediscovered in code review.
610610
| Chat resolver | 3-level: explicit → ContextVar → global | Process-global singleton | See [DECISIONS.md](DECISIONS.md#why-3-level-chat-resolver) |
611611
| PostableObject history | Cached in message history with real message ID | Not cached (skips history) | Upstream gap — posted messages should appear in thread/channel history |
612612
| Teams `msteams` transport key | Stripped from action values | Not stripped | Upstream gap — SDK-injected metadata should not leak to handlers |
613+
| 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`. |
614+
| 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. |
613615
| 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. |
614616
| Google Chat `<url\|text>` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `<url\|text>` as raw text (or parse the whole string as an autolink with a malformed URL) | Upstream 4.26 emits `<url\|text>` 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 `<url\|text>` 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. |
615617
| `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. |

pyproject.toml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,14 @@ redis = ["redis>=5.0"]
5252
postgres = ["asyncpg>=0.29"]
5353
crypto = ["cryptography>=42.0"]
5454
discord = ["pynacl>=1.5", "aiohttp>=3.9"]
55-
teams = ["aiohttp>=3.9"]
55+
teams = [
56+
"aiohttp>=3.9",
57+
# Official Microsoft Teams Apps Python SDK (issue #93 migration; mirrors upstream
58+
# adapter-teams@4.30.0's @microsoft/teams.* deps). Graph stays hand-rolled (no [graph] extra).
59+
"microsoft-teams-apps>=2.0.13",
60+
"microsoft-teams-api>=2.0.13",
61+
"microsoft-teams-cards>=2.0.13",
62+
]
5663
telegram = ["aiohttp>=3.9"]
5764
whatsapp = ["aiohttp>=3.9"]
5865
messenger = ["aiohttp>=3.9"]
@@ -68,6 +75,9 @@ all = [
6875
"pynacl>=1.5",
6976
"aiohttp>=3.9",
7077
"google-auth>=2.0",
78+
"microsoft-teams-apps>=2.0.13",
79+
"microsoft-teams-api>=2.0.13",
80+
"microsoft-teams-cards>=2.0.13",
7181
]
7282

7383
[build-system]
@@ -123,6 +133,11 @@ dev = [
123133
"pyjwt[crypto]>=2.8",
124134
"ruff>=0.4.0",
125135
"pyrefly==0.61.1",
136+
# Teams adapter SDK (issue #93) — in dev so CI (`uv sync --group dev`) installs it
137+
# and the Teams test suite runs; matches how the other optional adapter deps are wired.
138+
"microsoft-teams-apps>=2.0.13",
139+
"microsoft-teams-api>=2.0.13",
140+
"microsoft-teams-cards>=2.0.13",
126141
]
127142

128143
# ---------------------------------------------------------------------------
@@ -166,6 +181,9 @@ replace-imports-with-any = [
166181
# GitHub App auth
167182
"jwt",
168183
"jwt.*",
184+
# Teams — official MS Teams Apps SDK (optional teams extra)
185+
"microsoft_teams",
186+
"microsoft_teams.*",
169187
]
170188

171189
[tool.pyrefly.errors]

0 commit comments

Comments
 (0)