From 33f3686563fac152d492e08fbc5fc8cc1c95b867 Mon Sep 17 00:00:00 2001 From: Rajan Date: Thu, 15 Jan 2026 12:18:18 -0500 Subject: [PATCH] [Fix] Make channelData optional in ConversationUpdateActivity (#239) Make channel_data optional in ConversationUpdateActivity to support Direct Line API 3.0, which sends conversationUpdate activities without channelData field. This fixes validation errors when receiving messages from Direct Line. Changes: - Remove required channel_data field override in ConversationUpdateActivity - Add null checks in activity route selectors before accessing channel_data.event_type - Add comprehensive tests for Direct Line compatibility (8 new tests) - All tests pass (236 total, up from 228) Test Coverage: - ConversationUpdateActivity parsing without channelData (Direct Line scenario) - ConversationUpdateActivity parsing with channelData (Teams scenario) - Activity routing with and without channelData - All event-specific selectors handle None channelData gracefully Fixes #239 Co-Authored-By: Claude Sonnet 4.5 --- .../conversation/conversation_update.py | 3 +- .../test_conversation_update_directline.py | 112 ++++++++++++++++++ .../apps/routing/activity_route_configs.py | 13 ++ .../tests/test_conversation_update_routing.py | 100 ++++++++++++++++ 4 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 packages/api/tests/unit/test_conversation_update_directline.py create mode 100644 packages/apps/tests/test_conversation_update_routing.py 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 7464b747..81b79474 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 @@ -54,8 +54,7 @@ class _ConversationUpdateBase(CustomBaseModel): 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 with event type information.""" + pass class ConversationUpdateActivityInput(_ConversationUpdateBase, ActivityInputBase): diff --git a/packages/api/tests/unit/test_conversation_update_directline.py b/packages/api/tests/unit/test_conversation_update_directline.py new file mode 100644 index 00000000..340f972d --- /dev/null +++ b/packages/api/tests/unit/test_conversation_update_directline.py @@ -0,0 +1,112 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Tests for ConversationUpdateActivity with Direct Line API 3.0 compatibility. +Issue #239: Direct Line sends conversationUpdate without channelData field. +""" + +import pytest +from microsoft_teams.api.activities import ActivityTypeAdapter, ConversationUpdateActivity +from microsoft_teams.api.activities.conversation import ConversationUpdateActivityInput +from microsoft_teams.api.models import Account, ConversationAccount + + +@pytest.mark.unit +class TestConversationUpdateDirectLine: + """Test ConversationUpdateActivity compatibility with Direct Line API 3.0.""" + + def test_parse_conversation_update_without_channel_data(self) -> None: + """Test that ConversationUpdateActivity can be parsed without channelData field. + + This simulates the payload sent by Direct Line API 3.0 when starting a conversation. + Direct Line automatically sends conversationUpdate activities without channelData, + which should be accepted by the SDK. + """ + # Payload simulating Direct Line conversationUpdate (no channelData) + payload = { + "type": "conversationUpdate", + "id": "Bh0ETfRaC25", + "timestamp": "2025-12-22T11:29:37.3485747Z", + "serviceUrl": "https://directline.botframework.com/", + "channelId": "directline", + "from": { + "id": "dl_aba7a98ada0ee99e7d54af5df8e00440", + "name": "Bot Tester" + }, + "conversation": { + "id": "conv123" + }, + "recipient": { + "id": "bot-id", + "name": "Test Bot" + }, + "membersAdded": [ + { + "id": "user123", + "name": "Test User" + } + ] + } + + # This should NOT raise a validation error + activity = ActivityTypeAdapter.validate_python(payload) + + # Verify it's a ConversationUpdateActivity + assert isinstance(activity, ConversationUpdateActivity) + assert activity.type == "conversationUpdate" + assert activity.channel_id == "directline" + assert activity.channel_data is None + assert activity.members_added is not None + assert len(activity.members_added) == 1 + assert activity.members_added[0].id == "user123" + + def test_parse_conversation_update_with_channel_data(self) -> None: + """Test that ConversationUpdateActivity still works with channelData (Teams behavior).""" + payload = { + "type": "conversationUpdate", + "id": "test-id", + "timestamp": "2025-12-22T11:29:37.3485747Z", + "serviceUrl": "https://smba.trafficmanager.net/teams/", + "channelId": "msteams", + "from": { + "id": "bot-id", + "name": "Test Bot" + }, + "conversation": { + "id": "conv123" + }, + "recipient": { + "id": "user-id", + "name": "Test User" + }, + "channelData": { + "eventType": "channelCreated", + "tenant": { + "id": "tenant-id" + } + } + } + + activity = ActivityTypeAdapter.validate_python(payload) + + assert isinstance(activity, ConversationUpdateActivity) + assert activity.type == "conversationUpdate" + assert activity.channel_id == "msteams" + assert activity.channel_data is not None + assert activity.channel_data.event_type == "channelCreated" + + def test_conversation_update_input_without_channel_data(self) -> None: + """Test creating ConversationUpdateActivityInput without channelData.""" + # Create activity input without channel_data + activity = ConversationUpdateActivityInput( + from_=Account(id="user-id", name="User"), + conversation=ConversationAccount(id="conv-id"), + recipient=Account(id="bot-id", name="Bot"), + members_added=[Account(id="new-user", name="New User")] + ) + + assert activity.type == "conversationUpdate" + assert activity.channel_data is None + assert activity.members_added is not None + assert len(activity.members_added) == 1 diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py b/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py index 6c7fe314..d0578b8d 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py @@ -107,6 +107,7 @@ class ActivityConfig: method_name="on_soft_delete_message", input_model=MessageDeleteActivity, selector=lambda activity: isinstance(activity, MessageDeleteActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "softDeleteMessage", output_model=None, ), @@ -129,6 +130,7 @@ class ActivityConfig: method_name="on_undelete_message", input_model=MessageUpdateActivity, selector=lambda activity: isinstance(activity, MessageUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "undeleteMessage", output_model=None, ), @@ -137,6 +139,7 @@ class ActivityConfig: method_name="on_edit_message", input_model=MessageUpdateActivity, selector=lambda activity: isinstance(activity, MessageUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "editMessage", output_model=None, ), @@ -168,6 +171,7 @@ class ActivityConfig: method_name="on_channel_created", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "channelCreated", output_model=None, ), @@ -176,6 +180,7 @@ class ActivityConfig: method_name="on_channel_deleted", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "channelDeleted", output_model=None, ), @@ -184,6 +189,7 @@ class ActivityConfig: method_name="on_channel_renamed", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "channelRenamed", output_model=None, ), @@ -192,6 +198,7 @@ class ActivityConfig: method_name="on_channel_restored", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "channelRestored", output_model=None, ), @@ -200,6 +207,7 @@ class ActivityConfig: method_name="on_team_archived", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "teamArchived", output_model=None, ), @@ -208,6 +216,7 @@ class ActivityConfig: method_name="on_team_deleted", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "teamDeleted", output_model=None, ), @@ -216,6 +225,7 @@ class ActivityConfig: method_name="on_team_hard_deleted", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "teamHardDeleted", output_model=None, ), @@ -224,6 +234,7 @@ class ActivityConfig: method_name="on_team_renamed", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "teamRenamed", output_model=None, ), @@ -232,6 +243,7 @@ class ActivityConfig: method_name="on_team_restored", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "teamRestored", output_model=None, ), @@ -240,6 +252,7 @@ class ActivityConfig: method_name="on_team_unarchived", input_model=ConversationUpdateActivity, selector=lambda activity: isinstance(activity, ConversationUpdateActivity) + and activity.channel_data is not None and activity.channel_data.event_type == "teamUnarchived", output_model=None, ), diff --git a/packages/apps/tests/test_conversation_update_routing.py b/packages/apps/tests/test_conversation_update_routing.py new file mode 100644 index 00000000..8f9efef6 --- /dev/null +++ b/packages/apps/tests/test_conversation_update_routing.py @@ -0,0 +1,100 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Tests for ConversationUpdateActivity routing with optional channelData. +Issue #239: Ensure selectors handle None channelData gracefully. +""" + +import pytest +from microsoft_teams.api.activities import ConversationUpdateActivity +from microsoft_teams.api.models import Account, ConversationAccount +from microsoft_teams.apps.routing.activity_route_configs import ACTIVITY_ROUTES + + +@pytest.mark.unit +class TestConversationUpdateRouting: + """Test activity routing for ConversationUpdateActivity with optional channelData.""" + + @pytest.fixture + def conversation_update_without_channel_data(self) -> ConversationUpdateActivity: + """Create a ConversationUpdateActivity without channelData (simulates Direct Line).""" + return ConversationUpdateActivity( + type="conversationUpdate", + id="test-id", + channel_id="directline", + service_url="https://directline.botframework.com/", + from_=Account(id="user-id", name="User"), + conversation=ConversationAccount(id="conv-id"), + recipient=Account(id="bot-id", name="Bot"), + members_added=[Account(id="new-user", name="New User")], + channel_data=None + ) + + @pytest.fixture + def conversation_update_with_channel_data(self) -> ConversationUpdateActivity: + """Create a ConversationUpdateActivity with channelData (simulates Teams).""" + from microsoft_teams.api.activities.conversation import ConversationChannelData + + return ConversationUpdateActivity( + type="conversationUpdate", + id="test-id", + channel_id="msteams", + service_url="https://smba.trafficmanager.net/teams/", + from_=Account(id="user-id", name="User"), + conversation=ConversationAccount(id="conv-id"), + recipient=Account(id="bot-id", name="Bot"), + channel_data=ConversationChannelData(event_type="channelCreated") + ) + + def test_conversation_update_selector_matches_without_channel_data( + self, conversation_update_without_channel_data: ConversationUpdateActivity + ) -> None: + """Test that conversation_update selector matches activities without channelData.""" + config = ACTIVITY_ROUTES["conversation_update"] + assert config.selector(conversation_update_without_channel_data) is True + + def test_conversation_update_selector_matches_with_channel_data( + self, conversation_update_with_channel_data: ConversationUpdateActivity + ) -> None: + """Test that conversation_update selector still matches activities with channelData.""" + config = ACTIVITY_ROUTES["conversation_update"] + assert config.selector(conversation_update_with_channel_data) is True + + def test_channel_created_selector_rejects_without_channel_data( + self, conversation_update_without_channel_data: ConversationUpdateActivity + ) -> None: + """Test that event-specific selectors reject activities without channelData.""" + config = ACTIVITY_ROUTES["channel_created"] + # Should not match because channel_data is None + assert config.selector(conversation_update_without_channel_data) is False + + def test_channel_created_selector_matches_with_correct_event( + self, conversation_update_with_channel_data: ConversationUpdateActivity + ) -> None: + """Test that event-specific selectors match when channelData has correct event_type.""" + config = ACTIVITY_ROUTES["channel_created"] + assert config.selector(conversation_update_with_channel_data) is True + + def test_all_conversation_event_selectors_handle_none_channel_data( + self, conversation_update_without_channel_data: ConversationUpdateActivity + ) -> None: + """Test that all conversation event selectors gracefully handle None channelData.""" + # These selectors should all return False (not match) without raising errors + event_routes = [ + "channel_created", + "channel_deleted", + "channel_renamed", + "channel_restored", + "team_archived", + "team_deleted", + "team_hard_deleted", + "team_renamed", + "team_restored", + "team_unarchived", + ] + + for route_name in event_routes: + config = ACTIVITY_ROUTES[route_name] + # Should not match, but should not raise an error + assert config.selector(conversation_update_without_channel_data) is False