Repository: microsoft/botbuilder-python

Issue:
I can Update and Delete activities from a Skill (Python)
See [parent](https://github.com/microsoft/botframework-sdk/issues/5788)


Discussion:
Hi @axelsrz, the dotnet PR for this one has been merged into master and can be ported to Python whenever you are ready. 

Resolving patch:
diff --git a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py
--- a/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py
+++ b/libraries/botbuilder-core/botbuilder/core/skills/skill_handler.py
@@ -62,9 +62,9 @@ async def on_send_to_conversation(
         This method allows you to send an activity to the end of a conversation.
 
         This is slightly different from ReplyToActivity().
-        * SendToConversation(conversationId) - will append the activity to the end
+        * SendToConversation(conversation_id) - will append the activity to the end
         of the conversation according to the timestamp or semantics of the channel.
-        * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply
+        * ReplyToActivity(conversation_id,ActivityId) - adds the activity as a reply
         to another activity, if the channel supports it. If the channel does not
         support nested replies, ReplyToActivity falls back to SendToConversation.
 
@@ -97,9 +97,9 @@ async def on_reply_to_activity(
         This method allows you to reply to an activity.
 
         This is slightly different from SendToConversation().
-        * SendToConversation(conversationId) - will append the activity to the end
+        * SendToConversation(conversation_id) - will append the activity to the end
         of the conversation according to the timestamp or semantics of the channel.
-        * ReplyToActivity(conversationId,ActivityId) - adds the activity as a reply
+        * ReplyToActivity(conversation_id,ActivityId) - adds the activity as a reply
         to another activity, if the channel supports it. If the channel does not
         support nested replies, ReplyToActivity falls back to SendToConversation.
 
@@ -111,6 +111,8 @@ async def on_reply_to_activity(
         :type claims_identity: :class:`botframework.connector.auth.ClaimsIdentity`
         :param conversation_id:The conversation ID.
         :type conversation_id: str
+        :param activity_id: Activity ID to send.
+        :type activity_id: str
         :param activity: Activity to send.
         :type activity: Activity
         :return:
@@ -119,13 +121,66 @@ async def on_reply_to_activity(
             claims_identity, conversation_id, activity_id, activity,
         )
 
-    async def _process_activity(
+    async def on_delete_activity(
+        self, claims_identity: ClaimsIdentity, conversation_id: str, activity_id: str
+    ):
+        skill_conversation_reference = await self._get_skill_conversation_reference(
+            conversation_id
+        )
+
+        async def callback(turn_context: TurnContext):
+            turn_context.turn_state[
+                self.SKILL_CONVERSATION_REFERENCE_KEY
+            ] = skill_conversation_reference
+            await turn_context.delete_activity(activity_id)
+
+        await self._adapter.continue_conversation(
+            skill_conversation_reference.conversation_reference,
+            callback,
+            claims_identity=claims_identity,
+            audience=skill_conversation_reference.oauth_scope,
+        )
+
+    async def on_update_activity(
         self,
         claims_identity: ClaimsIdentity,
         conversation_id: str,
-        reply_to_activity_id: str,
+        activity_id: str,
         activity: Activity,
     ) -> ResourceResponse:
+        skill_conversation_reference = await self._get_skill_conversation_reference(
+            conversation_id
+        )
+
+        resource_response: ResourceResponse = None
+
+        async def callback(turn_context: TurnContext):
+            nonlocal resource_response
+            turn_context.turn_state[
+                self.SKILL_CONVERSATION_REFERENCE_KEY
+            ] = skill_conversation_reference
+            activity.apply_conversation_reference(
+                skill_conversation_reference.conversation_reference
+            )
+            turn_context.activity.id = activity_id
+            turn_context.activity.caller_id = (
+                f"{CallerIdConstants.bot_to_bot_prefix}"
+                f"{JwtTokenValidation.get_app_id_from_claims(claims_identity.claims)}"
+            )
+            resource_response = await turn_context.update_activity(activity)
+
+        await self._adapter.continue_conversation(
+            skill_conversation_reference.conversation_reference,
+            callback,
+            claims_identity=claims_identity,
+            audience=skill_conversation_reference.oauth_scope,
+        )
+
+        return resource_response or ResourceResponse(id=str(uuid4()).replace("-", ""))
+
+    async def _get_skill_conversation_reference(
+        self, conversation_id: str
+    ) -> SkillConversationReference:
         # Get the SkillsConversationReference
         conversation_reference_result = await self._conversation_id_factory.get_conversation_reference(
             conversation_id
@@ -135,11 +190,10 @@ async def _process_activity(
         # or a ConversationReference (the old way, but still here for compatibility).  If a
         # ConversationReference is returned, build a new SkillConversationReference to simplify
         # the remainder of this method.
-        skill_conversation_reference: SkillConversationReference = None
         if isinstance(conversation_reference_result, SkillConversationReference):
-            skill_conversation_reference = conversation_reference_result
+            skill_conversation_reference: SkillConversationReference = conversation_reference_result
         else:
-            skill_conversation_reference = SkillConversationReference(
+            skill_conversation_reference: SkillConversationReference = SkillConversationReference(
                 conversation_reference=conversation_reference_result,
                 oauth_scope=(
                     GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE
@@ -154,6 +208,19 @@ async def _process_activity(
         if not skill_conversation_reference.conversation_reference:
             raise KeyError("conversationReference not found")
 
+        return skill_conversation_reference
+
+    async def _process_activity(
+        self,
+        claims_identity: ClaimsIdentity,
+        conversation_id: str,
+        reply_to_activity_id: str,
+        activity: Activity,
+    ) -> ResourceResponse:
+        skill_conversation_reference = await self._get_skill_conversation_reference(
+            conversation_id
+        )
+
         # If an activity is sent, return the ResourceResponse
         resource_response: ResourceResponse = None
 


Test patch:
diff --git a/libraries/botbuilder-core/tests/skills/test_skill_handler.py b/libraries/botbuilder-core/tests/skills/test_skill_handler.py
--- a/libraries/botbuilder-core/tests/skills/test_skill_handler.py
+++ b/libraries/botbuilder-core/tests/skills/test_skill_handler.py
@@ -1,5 +1,6 @@
 import hashlib
 import json
+from datetime import datetime
 from uuid import uuid4
 from asyncio import Future
 from typing import Dict, List, Callable
@@ -204,6 +205,8 @@ async def test_on_send_to_conversation(self):
         self._conversation_id = await self._test_id_factory.create_skill_conversation_id(
             self._conversation_reference
         )
+        # python 3.7 doesn't support AsyncMock, change this when min ver is 3.8
+        send_activities_called = False
 
         mock_adapter = Mock()
 
@@ -214,36 +217,55 @@ async def continue_conversation(
             claims_identity: ClaimsIdentity = None,
             audience: str = None,
         ):  # pylint: disable=unused-argument
-            await callback(
-                TurnContext(
-                    mock_adapter,
-                    conversation_reference_extension.get_continuation_activity(
-                        self._conversation_reference
-                    ),
-                )
+            # Invoke the callback created by the handler so we can assert the rest of the execution.
+            turn_context = TurnContext(
+                mock_adapter,
+                conversation_reference_extension.get_continuation_activity(
+                    self._conversation_reference
+                ),
             )
+            await callback(turn_context)
+
+            # Assert the callback set the right properties.
+            assert (
+                f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}"
+            ), turn_context.activity.caller_id
 
         async def send_activities(
             context: TurnContext, activities: List[Activity]
         ):  # pylint: disable=unused-argument
+            # Messages should not have a caller id set when sent back to the caller.
+            nonlocal send_activities_called
+            assert activities[0].caller_id is None
+            assert activities[0].reply_to_id is None
+            send_activities_called = True
             return [ResourceResponse(id="resourceId")]
 
         mock_adapter.continue_conversation = continue_conversation
         mock_adapter.send_activities = send_activities
 
-        sut = self.create_skill_handler_for_testing(mock_adapter)
-
-        activity = Activity(type=ActivityTypes.message, attachments=[], entities=[])
-        TurnContext.apply_conversation_reference(activity, self._conversation_reference)
-
-        assert not activity.caller_id
+        types_to_test = [
+            ActivityTypes.end_of_conversation,
+            ActivityTypes.event,
+            ActivityTypes.message,
+        ]
+
+        for activity_type in types_to_test:
+            with self.subTest(act_type=activity_type):
+                send_activities_called = False
+                activity = Activity(type=activity_type, attachments=[], entities=[])
+                TurnContext.apply_conversation_reference(
+                    activity, self._conversation_reference
+                )
+                sut = self.create_skill_handler_for_testing(mock_adapter)
 
-        resource_response = await sut.test_on_send_to_conversation(
-            self._claims_identity, self._conversation_id, activity
-        )
+                resource_response = await sut.test_on_send_to_conversation(
+                    self._claims_identity, self._conversation_id, activity
+                )
 
-        assert activity.caller_id is None
-        assert resource_response.id == "resourceId"
+                if activity_type == ActivityTypes.message:
+                    assert send_activities_called
+                    assert resource_response.id == "resourceId"
 
     async def test_forwarding_on_send_to_conversation(self):
         self._conversation_id = await self._test_id_factory.create_skill_conversation_id(
@@ -282,69 +304,186 @@ async def side_effect(
         assert response.id is resource_response_id
 
     async def test_on_reply_to_activity(self):
+        resource_response_id = "resourceId"
         self._conversation_id = await self._test_id_factory.create_skill_conversation_id(
             self._conversation_reference
         )
 
-        mock_adapter = Mock()
-        mock_adapter.continue_conversation = MagicMock(return_value=Future())
-        mock_adapter.continue_conversation.return_value.set_result(Mock())
-        mock_adapter.send_activities = MagicMock(return_value=Future())
-        mock_adapter.send_activities.return_value.set_result([])
+        types_to_test = [
+            ActivityTypes.end_of_conversation,
+            ActivityTypes.event,
+            ActivityTypes.message,
+        ]
+
+        for activity_type in types_to_test:
+            with self.subTest(act_type=activity_type):
+                mock_adapter = Mock()
+                mock_adapter.continue_conversation = MagicMock(return_value=Future())
+                mock_adapter.continue_conversation.return_value.set_result(Mock())
+                mock_adapter.send_activities = MagicMock(return_value=Future())
+                mock_adapter.send_activities.return_value.set_result(
+                    [ResourceResponse(id=resource_response_id)]
+                )
 
-        sut = self.create_skill_handler_for_testing(mock_adapter)
+                sut = self.create_skill_handler_for_testing(mock_adapter)
 
-        activity = Activity(type=ActivityTypes.message, attachments=[], entities=[])
-        activity_id = str(uuid4())
-        TurnContext.apply_conversation_reference(activity, self._conversation_reference)
+                activity = Activity(type=activity_type, attachments=[], entities=[])
+                activity_id = str(uuid4())
+                TurnContext.apply_conversation_reference(
+                    activity, self._conversation_reference
+                )
 
-        await sut.test_on_reply_to_activity(
-            self._claims_identity, self._conversation_id, activity_id, activity
-        )
+                resource_response = await sut.test_on_reply_to_activity(
+                    self._claims_identity, self._conversation_id, activity_id, activity
+                )
 
-        args, kwargs = mock_adapter.continue_conversation.call_args_list[0]
+                # continue_conversation validation
+                (
+                    args_continue,
+                    kwargs_continue,
+                ) = mock_adapter.continue_conversation.call_args_list[0]
+                mock_adapter.continue_conversation.assert_called_once()
 
-        assert isinstance(args[0], ConversationReference)
-        assert callable(args[1])
-        assert isinstance(kwargs["claims_identity"], ClaimsIdentity)
+                assert isinstance(args_continue[0], ConversationReference)
+                assert callable(args_continue[1])
+                assert isinstance(kwargs_continue["claims_identity"], ClaimsIdentity)
+
+                turn_context = TurnContext(
+                    mock_adapter,
+                    conversation_reference_extension.get_continuation_activity(
+                        self._conversation_reference
+                    ),
+                )
+                await args_continue[1](turn_context)
+                # assert the callback set the right properties.
+                assert (
+                    f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}"
+                ), turn_context.activity.caller_id
+
+                if activity_type == ActivityTypes.message:
+                    # send_activities validation
+                    (args_send, _,) = mock_adapter.send_activities.call_args_list[0]
+                    activity_from_send = args_send[1][0]
+                    assert activity_from_send.caller_id is None
+                    assert activity_from_send.reply_to_id, activity_id
+                    assert resource_response.id, resource_response_id
+                else:
+                    # Assert mock SendActivitiesAsync wasn't called
+                    mock_adapter.send_activities.assert_not_called()
+
+    async def test_on_update_activity(self):
+        self._conversation_id = await self._test_id_factory.create_skill_conversation_id(
+            self._conversation_reference
+        )
+        resource_response_id = "resourceId"
+        called_continue = False
+        called_update = False
 
-        await args[1](
-            TurnContext(
+        mock_adapter = Mock()
+        activity = Activity(type=ActivityTypes.message, attachments=[], entities=[])
+        activity_id = str(uuid4())
+        message = activity.text = f"TestUpdate {datetime.now()}."
+
+        async def continue_conversation(
+            reference: ConversationReference,
+            callback: Callable,
+            bot_id: str = None,
+            claims_identity: ClaimsIdentity = None,
+            audience: str = None,
+        ):  # pylint: disable=unused-argument
+            # Invoke the callback created by the handler so we can assert the rest of the execution.
+            nonlocal called_continue
+            turn_context = TurnContext(
                 mock_adapter,
                 conversation_reference_extension.get_continuation_activity(
                     self._conversation_reference
                 ),
             )
+            await callback(turn_context)
+
+            # Assert the callback set the right properties.
+            assert (
+                f"{CallerIdConstants.bot_to_bot_prefix}{self.skill_id}"
+            ), turn_context.activity.caller_id
+            called_continue = True
+
+        async def update_activity(
+            context: TurnContext,  # pylint: disable=unused-argument
+            new_activity: Activity,
+        ) -> ResourceResponse:
+            # Assert the activity being sent.
+            nonlocal called_update
+            assert activity_id, new_activity.reply_to_id
+            assert message, new_activity.text
+            called_update = True
+
+            return ResourceResponse(id=resource_response_id)
+
+        mock_adapter.continue_conversation = continue_conversation
+        mock_adapter.update_activity = update_activity
+
+        sut = self.create_skill_handler_for_testing(mock_adapter)
+        resource_response = await sut.test_on_update_activity(
+            self._claims_identity, self._conversation_id, activity_id, activity
         )
-        assert activity.caller_id is None
 
-    async def test_on_update_activity(self):
-        self._conversation_id = ""
+        assert called_continue
+        assert called_update
+        assert resource_response, resource_response_id
 
-        mock_adapter = Mock()
+    async def test_on_delete_activity(self):
+        self._conversation_id = await self._test_id_factory.create_skill_conversation_id(
+            self._conversation_reference
+        )
 
-        sut = self.create_skill_handler_for_testing(mock_adapter)
+        resource_response_id = "resourceId"
+        called_continue = False
+        called_delete = False
 
-        activity = Activity(type=ActivityTypes.message, attachments=[], entities=[])
+        mock_adapter = Mock()
         activity_id = str(uuid4())
 
-        with self.assertRaises(BotActionNotImplementedError):
-            await sut.test_on_update_activity(
-                self._claims_identity, self._conversation_id, activity_id, activity
+        async def continue_conversation(
+            reference: ConversationReference,
+            callback: Callable,
+            bot_id: str = None,
+            claims_identity: ClaimsIdentity = None,
+            audience: str = None,
+        ):  # pylint: disable=unused-argument
+            # Invoke the callback created by the handler so we can assert the rest of the execution.
+            nonlocal called_continue
+            turn_context = TurnContext(
+                mock_adapter,
+                conversation_reference_extension.get_continuation_activity(
+                    self._conversation_reference
+                ),
             )
+            await callback(turn_context)
+            called_continue = True
 
-    async def test_on_delete_activity(self):
-        self._conversation_id = ""
+        async def delete_activity(
+            context: TurnContext,  # pylint: disable=unused-argument
+            conversation_reference: ConversationReference,
+        ) -> ResourceResponse:
+            # Assert the activity being sent.
+            nonlocal called_delete
+            # Assert the activity_id being deleted.
+            assert activity_id, conversation_reference.activity_id
+            called_delete = True
 
-        mock_adapter = Mock()
+            return ResourceResponse(id=resource_response_id)
+
+        mock_adapter.continue_conversation = continue_conversation
+        mock_adapter.delete_activity = delete_activity
 
         sut = self.create_skill_handler_for_testing(mock_adapter)
-        activity_id = str(uuid4())
 
-        with self.assertRaises(BotActionNotImplementedError):
-            await sut.test_on_delete_activity(
-                self._claims_identity, self._conversation_id, activity_id
-            )
+        await sut.test_on_delete_activity(
+            self._claims_identity, self._conversation_id, activity_id
+        )
+
+        assert called_continue
+        assert called_delete
 
     async def test_on_get_activity_members(self):
         self._conversation_id = ""