Skip to content

feat(teams): migrate outbound send/edit/delete/typing to the SDK (#93 PR 2/4)#144

Merged
patrick-chinchill merged 1 commit into
mainfrom
feat/teams-sdk-pr2-outbound
Jun 18, 2026
Merged

feat(teams): migrate outbound send/edit/delete/typing to the SDK (#93 PR 2/4)#144
patrick-chinchill merged 1 commit into
mainfrom
feat/teams-sdk-pr2-outbound

Conversation

@patrick-chinchill

Copy link
Copy Markdown
Collaborator

Summary

PR 2 of the Teams adapter migration to the official microsoft-teams-apps SDK (issue #93). PR 1 (inbound + auth via the SDK App) is merged; this PR migrates the outbound operations, mirroring upstream adapter-teams@chat@4.30.0 (index.ts). Streaming and Graph reads stay hand-rolled (PR 3 / separate).

What changed

Outbound ops now delegate to the SDK

method SDK surface
post_message, start_typing self._app.send(conversation_id, activity) (returns a SentActivity with .id)
edit_message self._app.api.conversations.activities(conversation_id).update(message_id, activity)
delete_message self._app.api.conversations.activities(conversation_id).delete(message_id)

The SDK splits input from output activity models, so we build MessageActivityInput / TypingActivityInput (the ActivityParams union that app.send / activities.update accept) from the existing camelCase wire dict. That dict is still returned as RawMessage.raw, so the public Adapter contract is preserved exactly (text/markdown format, file/attachment shape, returned SentMessage id).

Per-thread service-URL routing

Thread IDs encode a per-thread service URL, but the SDK binds the App's ApiClient to a single URL at construction (and app.send reads self.api.service_url into the outgoing ConversationReference). _point_app_api_at retargets the real ApiClient's service-url chain — validated against the SSRF allow-list first, exactly as the retired hand-rolled senders did — before each call, so different regions / sovereign clouds reach the right endpoint. Errors pass the raw SDK exception to handleTeamsError, which already maps the SDK exception shape (401 → AuthenticationError, 429 → AdapterRateLimitError, …).

Token-cache untangle (the shared-token bug)

_get_access_token (Bot Framework, scope api.botframework.com) and _get_graph_token (Graph, scope graph.microsoft.com) previously shared _access_token / _token_expiry, so the last writer clobbered the other scope — a Graph read could ship a Bot Framework token and vice versa. Graph now caches on dedicated _graph_token / _graph_token_expiry fields. _get_access_token + its fields are retained for the still-hand-rolled Bot Framework callers (native streaming via _teams_send in PR 3, and open_dm), now isolated by scope.

Streaming kept intact

_teams_send (and the Bot Framework token it uses) remain for the streaming envelope (channelData / entities / server-assigned streamId), which app.send does not surface. Removed the now-unused _teams_update / _teams_delete aiohttp helpers.

Tests

  • Outbound tests now assert the SDK app.send / app.api.conversations.activities are called (not aiohttp), via small AsyncMock helpers.
  • Token-cache isolation regression (TestTokenCacheIsolation): a scope-routed token endpoint proves the two scopes no longer collide, cache on separate fields, and a warm Bot Framework cache does not satisfy a Graph fetch.
  • Per-thread service-url routing regression (TestOutboundServiceUrlRouting): exercises the real ApiClient chain (only the leaf update/send patched) so the retarget can't silently no-op.

SDK-forced divergence

  • Upstream constructs MessageActivity / TypingActivity; the Python SDK only accepts the input variants (MessageActivityInput / TypingActivityInput) on send / update. Behavior is identical on the wire.
  • app.send requires app.initialize() to have run (the SDK raises otherwise). This matches the adapter lifecycle (initialize() always precedes outbound ops) and upstream.

Gauntlet

ruff check OK · ruff format --check OK · audit_test_quality 0 hard failures · pyrefly check 0 errors · pytest 4797 passed, 3 skipped.

Not touched (out of scope)

Streaming (stream(), _TeamsStreamSession, _emit_streaming_activity), Graph reads (fetch_* / get_user — only their token-cache field changed), cards.py, format_converter.py, thread-id, the inbound path (PR 1), and the public TeamsAdapterConfig fields.

…PR 2/4)

Route the outbound public Adapter methods through the Microsoft Teams SDK
``App``, mirroring upstream adapter-teams@chat@4.30.0, and untangle the
Bot Framework / Graph token caches.

Outbound delegation:
- post_message / start_typing -> self._app.send(conversation_id, activity)
- edit_message -> self._app.api.conversations.activities(id).update(msg_id, activity)
- delete_message -> self._app.api.conversations.activities(id).delete(msg_id)

The SDK splits input from output activity models, so we build
MessageActivityInput / TypingActivityInput (the ActivityParams union that
app.send / activities.update accept) from the existing camelCase wire dict.
The dict is still returned as RawMessage.raw, preserving the public
contract (attachment shape, file delivery, returned SentMessage id).

Per-thread routing: thread IDs encode a service URL, but the SDK binds the
App's ApiClient to one URL at construction. _point_app_api_at retargets the
real ApiClient's service-url chain (validated against the SSRF allow-list
first) before each call, so different regions / sovereign clouds reach the
right endpoint. Errors now pass the raw SDK exception to handleTeamsError,
which already understands the SDK exception shape (401 -> AuthenticationError,
429 -> AdapterRateLimitError, etc.).

Token-cache untangle: _get_access_token (Bot Framework) and _get_graph_token
(Graph) previously shared _access_token / _token_expiry, so the last writer
clobbered the other scope's token (a Graph read could ship a Bot Framework
token and vice versa). Graph now caches on dedicated _graph_token /
_graph_token_expiry fields. _get_access_token + its fields are retained for
the still-hand-rolled Bot Framework callers (native streaming via _teams_send
in PR 3, and open_dm), now isolated by scope.

Streaming is untouched: _teams_send (and its _get_access_token token) remain
for the streaming envelope + server-assigned streamId, which app.send does not
surface. Removed the now-unused _teams_update / _teams_delete aiohttp helpers.

Tests: outbound tests assert the SDK app.send / app.api.conversations.activities
are called (not aiohttp); added a token-cache isolation regression proving the
two scopes no longer collide, and a per-thread service-url routing regression
exercising the real ApiClient chain.
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@patrick-chinchill, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 22 minutes and 26 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2c9d0558-1a1c-4f78-b997-d51d09cdeadb

📥 Commits

Reviewing files that changed from the base of the PR and between 2676f0b and 54dc77e.

📒 Files selected for processing (4)
  • src/chat_sdk/adapters/teams/adapter.py
  • tests/test_teams_adapter.py
  • tests/test_teams_coverage.py
  • tests/test_teams_extended.py

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.


def routed_post(url, **kwargs):
scope = (kwargs.get("data") or {}).get("scope", "")
if "graph.microsoft.com" in scope:
scope = (kwargs.get("data") or {}).get("scope", "")
if "graph.microsoft.com" in scope:
return session._make_cm(graph_resp)
if "botframework.com" in scope:

@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 migrates outbound operations (posting, editing, deleting, and typing) in the Teams adapter to use the official Microsoft Teams SDK instead of hand-rolled REST API helpers. It also resolves a token cache collision issue (#93) by introducing dedicated fields for the Microsoft Graph token. However, a critical race condition was identified in the new _point_app_api_at method: mutating the shared API client's service URL concurrently across asynchronous calls can cause requests to be misrouted. It is recommended to serialize these outbound operations using an asyncio.Lock.

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 +1343 to +1374
def _point_app_api_at(self, service_url: str) -> None:
"""Aim the SDK ``App``'s Bot Framework client at ``service_url``.

The migrated outbound paths call ``self._app.send(...)`` and
``self._app.api.conversations.activities(...)`` directly (parity with
upstream ``this.app.send`` / ``this.app.api.conversations``). The SDK
binds the App's :class:`ApiClient` to a single service URL at
construction, and ``app.send`` reads ``self.api.service_url`` into the
outgoing :class:`ConversationReference`. Our thread IDs encode a
per-thread service URL, so before each call we retarget the App's API
client — validating against the SSRF allow-list first, exactly as the
retired hand-rolled senders did.

The setter walks the real :class:`ApiClient`'s service-url chain
(the client itself, its ``conversations`` sub-client, and that
sub-client's ``activities_client``). It is defensive about test doubles
that replace ``self._app.api`` with a mock lacking those attributes —
an ``AttributeError`` there is harmless because the mock ignores the
service URL anyway.
"""
_validate_service_url(service_url)
normalized = service_url.rstrip("/")
api = self._app.api
try:
api.service_url = normalized
conversations = api.conversations
conversations.service_url = normalized
conversations.activities_client.service_url = normalized
except AttributeError:
# ``self._app.api`` is a test double without the real client's
# service-url chain; nothing to retarget.
pass

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

Critical Race Condition in Concurrent Requests

Since TeamsAdapter is a shared instance (often a singleton) handling concurrent requests, mutating the shared self._app.api service URL in _point_app_api_at introduces a critical race condition.

When multiple concurrent outbound operations (e.g., post_message, edit_message, delete_message, start_typing) are in-flight for different threads with different regional or sovereign service URLs (such as https://smba.trafficmanager.net/amer/ vs https://smba.trafficmanager.net/emea/), they will concurrently overwrite self._app.api.service_url. Because these outbound operations are asynchronous (await self._app.send(...)), one request can yield control and have its service URL overwritten by another concurrent request before or during its API call. This will cause requests to be routed to the wrong regional endpoints, resulting in 404 Not Found or 401 Unauthorized errors.

Suggested Fix

To prevent this, serialize outbound operations that modify the shared API client's service URL using an asyncio.Lock. For example, you can define an async context manager to safely acquire a lock, point the API, and release the lock after the request completes:

# In __init__:
# self._api_lock = asyncio.Lock()

@asynccontextmanager
async def _api_at(self, service_url: str):
    async with self._api_lock:
        self._point_app_api_at(service_url)
        yield

And then wrap the outbound calls:

async with self._api_at(decoded.service_url):
    sent = await self._app.send(...)

async def test_delete_message_failure(self):
adapter = _make_adapter(logger=_make_logger())
adapter._teams_delete = AsyncMock(side_effect=Exception("delete failed"))
_update, _delete = _mock_app_activities(adapter, delete_side_effect=Exception("delete failed"))
async def test_delete_message_failure(self):
adapter = _make_adapter(logger=_make_logger())
adapter._teams_delete = AsyncMock(side_effect=Exception("delete failed"))
_update, _delete = _mock_app_activities(adapter, delete_side_effect=Exception("delete failed"))
@patrick-chinchill patrick-chinchill marked this pull request as ready for review June 18, 2026 21:40
@patrick-chinchill patrick-chinchill merged commit a9f0c80 into main Jun 18, 2026
8 of 9 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 54dc77eded

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if hasattr(error, "status"):
error_dict["statusCode"] = error.status
_handle_teams_error(error_dict, "postMessage")
_handle_teams_error(error, "postMessage")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve SDK HTTP statuses when mapping errors

When the SDK call fails, this now passes the raw exception into _handle_teams_error, but the Microsoft Teams SDK HTTP client raises httpx.HTTPStatusError via response.raise_for_status(), where the status is on error.response.status_code rather than direct status_code/status/code attributes. In real 401/403/429 responses from app.send (and the same pattern in edit/delete), the mapper therefore falls through to a generic NetworkError instead of the existing AuthenticationError, permission, or rate-limit errors, so callers lose the actionable failure type.

Useful? React with 👍 / 👎.

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.

2 participants