From c022e0fa6c2341acdae7624b9913c22ba1fb7d18 Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:53:25 -0700 Subject: [PATCH 1/2] Make channelData subfields optional to avoid throw (#564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #563 The Teams service can send nested `channelData` objects as empty objects ( `{}` ). Because `AppInfo.id` was required, Pydantic raised a `ValidationError`, and since `ActivityTypeAdapter.validate_python` has no fallback, the entire activity was rejected, so affected bots silently stopped responding. Reported against 2.0.15, the first release containing #504. Confirmed to affect customers, currently in 1:1 chats, with exposure growing as an upstream server-side rollout ramps. ## Root cause `channelData.app` is optional, so an absent `app` was always fine, but a present but empty one wasn't. Auditing the model graph showed this isn't unique to `app`: 98 sites exist where an optional parent points at a child with required fields. Seven were reachable from a plain message activity: ``` channelData.app={} -> channelData.app.id Field required channelData.channel={} -> channelData.channel.id Field required channelData.team={} -> channelData.team.id Field required channelData.tenant={} -> channelData.tenant.id Field required channelData.settings={} -> channelData.settings.selectedChannel attachments=[{}] -> attachments.0.contentType entities=[{}] -> entities.0._unknown.type ``` ## Change Make these inbound-only fields optional: `AppInfo.id`, `ChannelInfo.id`, `TeamInfo.id`, `TenantInfo.id`, `ChannelDataSettings.selected_channel`, `Attachment.content_type`, `EntityBase.type`. `MessageUpdateChannelData` and `MessageDeleteChannelData` both inherit `ChannelData`, so they're covered without separate edits. `EntityBase.type` is relaxed at the base rather than overridden on `UnknownEntity`: every concrete entity already overrides `type` with a `Literal` default, so `UnknownEntity` is the only class inheriting it raw. This also means an entity type introduced after this SDK version no longer drops the message. ## Tests New `test_empty_inbound_objects.py` covering each empty-object case, an unrecognized entity type, that populated values still parse, and that absent `channelData` is unchanged. ## Validation - 852 tests pass across `packages/api` + `packages/apps`  - `ruff format`  / `ruff check`  clean - pyright: zero net new errors ## Notes This is the defensive half. The empty object is a service-side wire-contract issue and has been raised with that team separately; this change means the SDK degrades gracefully regardless. (cherry picked from commit 5fcd8fab0b6ca1251fbcc35880f1f690dd6931c4) --- .../api/models/attachment/attachment.py | 2 +- .../api/models/channel_data/app_info.py | 2 +- .../api/models/channel_data/channel_info.py | 2 +- .../api/models/channel_data/settings.py | 4 +- .../api/models/channel_data/team_info.py | 2 +- .../api/models/channel_data/tenant_info.py | 4 +- .../api/models/entity/entity_base.py | 4 +- .../tests/unit/test_empty_inbound_objects.py | 92 +++++++++++++++++++ 8 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 packages/api/tests/unit/test_empty_inbound_objects.py diff --git a/packages/api/src/microsoft_teams/api/models/attachment/attachment.py b/packages/api/src/microsoft_teams/api/models/attachment/attachment.py index 69d73af9d..7617e159b 100644 --- a/packages/api/src/microsoft_teams/api/models/attachment/attachment.py +++ b/packages/api/src/microsoft_teams/api/models/attachment/attachment.py @@ -11,7 +11,7 @@ class Attachment(CustomBaseModel): """A model representing an attachment.""" - content_type: str + content_type: Optional[str] = None "mimetype/Contenttype for the file" content_url: Optional[str] = None diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/app_info.py b/packages/api/src/microsoft_teams/api/models/channel_data/app_info.py index 3b973fce4..10fd4e44c 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/app_info.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/app_info.py @@ -13,7 +13,7 @@ class AppInfo(CustomBaseModel): Describes an app """ - id: str + id: Optional[str] = None "Unique identifier representing an app" version: Optional[str] = None diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/channel_info.py b/packages/api/src/microsoft_teams/api/models/channel_data/channel_info.py index b5950306d..3e6277ee7 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/channel_info.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/channel_info.py @@ -13,7 +13,7 @@ class ChannelInfo(CustomBaseModel): A channel info object which describes the channel. """ - id: str + id: Optional[str] = None "Unique identifier representing a channel" name: Optional[str] = None diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/settings.py b/packages/api/src/microsoft_teams/api/models/channel_data/settings.py index f37f98114..f271fc690 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/settings.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/settings.py @@ -3,6 +3,8 @@ Licensed under the MIT License. """ +from typing import Optional + from ..custom_base_model import CustomBaseModel from .channel_info import ChannelInfo @@ -12,5 +14,5 @@ class ChannelDataSettings(CustomBaseModel): Settings within teams channel data specific to messages received in Microsoft Teams. """ - selected_channel: ChannelInfo + selected_channel: Optional[ChannelInfo] = None "Information about the selected Teams channel." diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/team_info.py b/packages/api/src/microsoft_teams/api/models/channel_data/team_info.py index e873bf373..3aeb82f8b 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/team_info.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/team_info.py @@ -14,7 +14,7 @@ class TeamInfo(CustomBaseModel): Describes a team """ - id: str + id: Optional[str] = None "Unique identifier representing a team" name: Optional[str] = None diff --git a/packages/api/src/microsoft_teams/api/models/channel_data/tenant_info.py b/packages/api/src/microsoft_teams/api/models/channel_data/tenant_info.py index 7404c0e2a..120856ed4 100644 --- a/packages/api/src/microsoft_teams/api/models/channel_data/tenant_info.py +++ b/packages/api/src/microsoft_teams/api/models/channel_data/tenant_info.py @@ -3,6 +3,8 @@ Licensed under the MIT License. """ +from typing import Optional + from ..custom_base_model import CustomBaseModel @@ -11,5 +13,5 @@ class TenantInfo(CustomBaseModel): Describes a tenant """ - id: str + id: Optional[str] = None "Unique identifier representing a tenant" diff --git a/packages/api/src/microsoft_teams/api/models/entity/entity_base.py b/packages/api/src/microsoft_teams/api/models/entity/entity_base.py index 8d4c495eb..519174f72 100644 --- a/packages/api/src/microsoft_teams/api/models/entity/entity_base.py +++ b/packages/api/src/microsoft_teams/api/models/entity/entity_base.py @@ -3,11 +3,13 @@ Licensed under the MIT License. """ +from typing import Optional + from ..custom_base_model import CustomBaseModel class EntityBase(CustomBaseModel): """Base entity for entity types.""" - type: str + type: Optional[str] = None "Type identifier for the entity." diff --git a/packages/api/tests/unit/test_empty_inbound_objects.py b/packages/api/tests/unit/test_empty_inbound_objects.py new file mode 100644 index 000000000..7022e1396 --- /dev/null +++ b/packages/api/tests/unit/test_empty_inbound_objects.py @@ -0,0 +1,92 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from typing import Any, Dict + +import pytest +from microsoft_teams.api.activities import ActivityTypeAdapter + + +def _activity(**overrides: Any) -> Dict[str, Any]: + """A minimal inbound message activity, with room for per-case overrides.""" + activity: Dict[str, Any] = { + "type": "message", + "id": "activity-id", + "text": "hello", + "channelId": "msteams", + "serviceUrl": "https://smba.trafficmanager.net/emea/tenant/", + "from": {"id": "user-id"}, + "conversation": {"id": "conversation-id"}, + "recipient": {"id": "bot-id"}, + } + activity.update(overrides) + return activity + + +@pytest.mark.parametrize( + "overrides", + [ + pytest.param({"channelData": {"app": {}}}, id="channel_data_app"), + pytest.param({"channelData": {"channel": {}}}, id="channel_data_channel"), + pytest.param({"channelData": {"team": {}}}, id="channel_data_team"), + pytest.param({"channelData": {"tenant": {}}}, id="channel_data_tenant"), + pytest.param({"channelData": {"settings": {}}}, id="channel_data_settings"), + pytest.param({"attachments": [{}]}, id="attachment"), + pytest.param({"entities": [{}]}, id="entity"), + ], +) +def test_activity_parses_when_nested_object_is_empty(overrides: Dict[str, Any]) -> None: + """ + The service can send nested objects as empty objects. These are inbound-only models, so a + missing field must not reject the whole activity. + + Regression test for https://github.com/microsoft/teams.py/issues/563, where a + ``channelData.app`` of ``{}`` raised a ValidationError and the activity was dropped. + """ + assert ActivityTypeAdapter.validate_python(_activity(**overrides)) is not None + + +def test_activity_parses_unrecognized_entity_type() -> None: + """An entity type this SDK version predates must not reject the activity.""" + activity = ActivityTypeAdapter.validate_python(_activity(entities=[{"type": "someFutureEntity"}])) + + assert activity.entities is not None + assert activity.entities[0].type == "someFutureEntity" + + +def test_populated_channel_data_is_preserved() -> None: + """Relaxing the required fields must not stop populated values from being parsed.""" + activity = ActivityTypeAdapter.validate_python( + _activity( + channelData={ + "app": {"id": "app-id", "version": "1.2.3"}, + "channel": {"id": "channel-id"}, + "team": {"id": "team-id"}, + "tenant": {"id": "tenant-id"}, + "settings": {"selectedChannel": {"id": "selected-channel-id"}}, + } + ) + ) + + channel_data = activity.channel_data + assert channel_data is not None + assert channel_data.app is not None + assert channel_data.app.id == "app-id" + assert channel_data.app.version == "1.2.3" + assert channel_data.channel is not None + assert channel_data.channel.id == "channel-id" + assert channel_data.team is not None + assert channel_data.team.id == "team-id" + assert channel_data.tenant is not None + assert channel_data.tenant.id == "tenant-id" + assert channel_data.settings is not None + assert channel_data.settings.selected_channel is not None + assert channel_data.settings.selected_channel.id == "selected-channel-id" + + +def test_absent_channel_data_still_parses() -> None: + """The pre-existing behaviour for a wholly absent channelData must be unchanged.""" + assert ActivityTypeAdapter.validate_python(_activity()).channel_data is None From f3f0d67af55077f7cf56fe06c316b554a2f39d1d Mon Sep 17 00:00:00 2001 From: Corina <14900841+corinagum@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:03:32 -0700 Subject: [PATCH 2/2] Set stable version 2.0.16 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index b38de5224..7344bad5c 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.0.15", + "version": "2.0.16", "versionHeightOffset": 1, "publicReleaseRefSpec": [ "^refs/heads/release/v\\d+\\.\\d+$"