diff --git a/packages/api/src/microsoft_teams/api/activities/conversation/conversation_update.py b/packages/api/src/microsoft_teams/api/activities/conversation/conversation_update.py index c13aa338..971020e1 100644 --- a/packages/api/src/microsoft_teams/api/activities/conversation/conversation_update.py +++ b/packages/api/src/microsoft_teams/api/activities/conversation/conversation_update.py @@ -3,7 +3,9 @@ Licensed under the MIT License. """ -from typing import List, Literal, Optional +from typing import Any, List, Literal, Optional + +from pydantic import model_validator from ...models import Account, ActivityBase, ActivityInputBase, ChannelData, CustomBaseModel @@ -49,10 +51,51 @@ class _ConversationUpdateBase(CustomBaseModel): class ConversationUpdateActivity(_ConversationUpdateBase, ActivityBase): - """Output model for received conversation update activities with required fields and read-only properties.""" + """Output model for received conversation update activities with required fields and read-only properties. + + Design note (channel_data field): + In Teams, conversationUpdate activities always include channelData with an eventType + discriminator (e.g. "channelCreated", "teamArchived"). The routing system + (activity_route_configs.py) relies on channel_data.event_type to dispatch to specific + handlers like on_channel_created() and on_team_archived(). + + However, non-Teams channels (notably Direct Line) send conversationUpdate activities + WITHOUT channelData, which would cause a Pydantic ValidationError if the field were + strictly required. See https://github.com/microsoft/teams.py/issues/239. + + Resolution: We keep channel_data as a REQUIRED field (preserving the type contract for + the 99% of developers building Teams bots) but add a model_validator that defaults it to + an empty ConversationChannelData() when missing from the incoming payload. This matches + the TypeScript SDK pattern, where the type declares channelData as required on + IConversationUpdateActivity (conversation-update.ts:25) but the router uses optional + chaining defensively (router.ts:73-87). + + The empty default means: + - Teams activities: channel_data is populated normally, event_type routes work as before + - Direct Line activities: channel_data is an empty ConversationChannelData (event_type=None), + so Teams-specific event handlers don't fire, but the generic on_conversation_update() + handler still does + - Type checkers see a non-optional ConversationChannelData — no None-guards needed + """ channel_data: ConversationChannelData # pyright: ignore [reportGeneralTypeIssues, reportIncompatibleVariableOverride] - """Channel data with event type information.""" + """Channel data with event type information. Always present — defaulted to empty for non-Teams channels.""" + + @model_validator(mode="before") + @classmethod + def _default_channel_data(cls, data: Any) -> Any: + """Supply an empty channelData when absent (e.g. Direct Line). + + Without this, Pydantic rejects the payload because channel_data is required. + The empty default preserves the required-field contract for Teams developers + while allowing non-Teams channels to send conversationUpdate without channelData. + See: https://github.com/microsoft/teams.py/issues/239 + """ + if isinstance(data, dict): + # Check both the snake_case field name and the camelCase alias + if "channel_data" not in data and "channelData" not in data: + data["channelData"] = {} + return data class ConversationUpdateActivityInput(_ConversationUpdateBase, ActivityInputBase):