fix: handle missing channelData in ConversationUpdateActivity for Direct Line#361
fix: handle missing channelData in ConversationUpdateActivity for Direct Line#361rajan-chari wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adjusts the ConversationUpdateActivity model in the API package to accept conversationUpdate payloads that omit channelData (notably from Direct Line), preventing Pydantic validation failures during activity parsing.
Changes:
- Changes
ConversationUpdateActivity.channel_datatoOptional[ConversationChannelData] = None(instead of required). - Removes the prior
pyright: ignoresuppression tied to the incompatible override.
| class ConversationUpdateActivity(_ConversationUpdateBase, ActivityBase): | ||
| """Output model for received conversation update activities with required fields and read-only properties.""" | ||
|
|
||
| channel_data: ConversationChannelData # pyright: ignore [reportGeneralTypeIssues, reportIncompatibleVariableOverride] | ||
| channel_data: Optional[ConversationChannelData] = None | ||
| """Channel data with event type information.""" |
There was a problem hiding this comment.
Making ConversationUpdateActivity.channel_data optional fixes Pydantic validation, but it also means downstream code must handle channel_data=None. The apps router evaluates all selectors (ActivityRouter.select_handlers iterates every route), and several selectors in packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py access activity.channel_data.event_type for ConversationUpdateActivity without a None-guard (e.g., channel_created, team_deleted, etc.). With a Direct Line conversationUpdate missing channel_data, this will raise AttributeError during routing and still fail the request. Update those selectors to short-circuit when activity.channel_data is None before reading event_type.
Manual Test Results — Revised Approach (model_validator)Tested on branch
Approach reviewThe revised model_validator approach is much cleaner than the previous Optional approach:
Pyright
CI
|
Direct Line sends conversationUpdate activities without channelData, causing a Pydantic ValidationError because channel_data is required. Add a model_validator(mode="before") that defaults to an empty ConversationChannelData when channelData is absent. This preserves the required-field type contract for Teams developers (who always receive channelData) while allowing non-Teams channels to deserialize cleanly. Matches the TypeScript SDK pattern: types declare channelData as required (conversation-update.ts:25), runtime handles absence defensively (router.ts:73-87). In Python, Pydantic enforces at runtime, so the validator bridges the gap. Fixes #239
3b0af3e to
bf2393b
Compare
Q&A: Should the model_validator default only apply for Direct Line?A reviewer question came up: should we check Answer: No — the universal approach is architecturally correct.
If early warning is desired: Add a log warning in the router or middleware — if |
corinagum
left a comment
There was a problem hiding this comment.
The TypeScript reference URLs in the PR description point to nicknow/teams.ts, which is a fork. Use the canonical microsoft/teams.ts URLs.
| - Type checkers see a non-optional ConversationChannelData — no None-guards needed | ||
| """ | ||
|
|
||
| channel_data: ConversationChannelData # pyright: ignore [reportGeneralTypeIssues, reportIncompatibleVariableOverride] |
There was a problem hiding this comment.
MessageUpdateActivity (and MessageDeleteActivity) have the same "required channel_data on output, optional on base" shape, and the route selectors at activity_route_configs.py:107, 129, 137 rely on activity.channel_data.event_type. If Direct Line ever sends a messageUpdate/messageDelete without channelData, the same ValidationError fires that this PR is fixing for conversationUpdate. The fix should be symmetric.
Note: MessageDeleteChannelData.event_type is Literal["softDeleteMessage"] with no default, so {} won't deserialize for it. A shared _default_channel_data mixin would need to be parameterized by the channel-data class.
|
|
||
| @model_validator(mode="before") | ||
| @classmethod | ||
| def _default_channel_data(cls, data: Any) -> Any: |
There was a problem hiding this comment.
The PR description says "all 577 existing tests pass" but adds none.
Add a regression test that parses a Direct Line payload.
| @model_validator(mode="before") | ||
| @classmethod | ||
| def _default_channel_data(cls, data: Any) -> Any: | ||
| """Supply an empty channelData when absent (e.g. Direct Line). |
There was a problem hiding this comment.
The docstring frames this as "incoming Direct Line payload" handling, but mode="before" + isinstance(data, dict) means it also fires when callers construct ConversationUpdateActivity(**some_dict) in their own code. Either tighten the docstring to describe the actual scope, or document that this is a permanent invariant of constructing the activity from a dict.
Summary
Bug: Direct Line sends
conversationUpdateactivities withoutchannelData, butConversationUpdateActivity.channel_datais a required field. Pydantic rejects the payload with aValidationErrorbefore any handler code runs.Fix: Add a
model_validator(mode="before")onConversationUpdateActivitythat injects an emptyConversationChannelDatawhenchannelDatais absent from the incoming payload. This keepschannel_dataas a required, non-optional field — preserving the type contract for Teams developers.Design rationale
We considered four approaches and chose Option C (default at deserialization) after cross-SDK review:
channel_dataOptionalWhy Option C: Matches the TypeScript SDK pattern. In TS,
IConversationUpdateActivitydeclareschannelDataas required (conversation-update.ts:25) but the router uses optional chaining defensively (router.ts:73-87). Python's Pydantic enforces types at runtime (unlike TS interfaces which are erased), so themodel_validatorbridges the gap — it runs before field validation and ensureschannelDatais always present.Result:
channel_datapopulated normally, event-type routing works as beforechannel_datais an emptyConversationChannelData(withevent_type=None), so Teams-specific handlers (e.g.on_channel_created) don't fire, but the genericon_conversation_updatehandler still doesConversationChannelData— no None-guards needed anywhereFiles changed
conversation_update.py— addedmodel_validator, extensive docstring explaining the designactivity_route_configs.py— reverted to original (no None-guards needed)Test plan
conversationUpdatewithoutchannelData— should succeed (wasValidationError)conversationUpdatewithchannelData— should parse and route normallyon_channel_created,on_team_archived, etc. still fire for Teams eventsFixes #239
🤖 Generated with Claude Code