Repository: microsoft/botbuilder-python

Issue:
Concat Attachments if exist in Prompt::AppendChoices
## Describe the bug
Attachments from Prompt message should combine with Attachments from Choice (if they are both present).

## To Reproduce
Steps to reproduce the behavior:
1. Create a ChoicePrompt with a Prompt message containing an attachment and some choices
2. set PromptOptions.Style to ListStyle.HeroCard
3. Message the bot
4. Notice the attachment on the Prompt message is not shown


Code:

https://github.com/microsoft/botbuilder-python/blob/master/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py#L221

## Expected behavior
Prompt message attachment should be combined with Choice Attachment (if both are present)

## Additional context
Parity with cs: https://github.com/microsoft/botbuilder-dotnet/pull/2520

[bug]



Discussion:

Resolving patch:
diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py
--- a/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py
+++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/choices/choice_factory.py
@@ -1,7 +1,7 @@
 # Copyright (c) Microsoft Corporation. All rights reserved.
 # Licensed under the MIT License.
 
-from typing import List
+from typing import List, Union
 
 from botbuilder.core import CardFactory, MessageFactory
 from botbuilder.schema import ActionTypes, Activity, CardAction, HeroCard, InputHints
@@ -17,7 +17,7 @@ class ChoiceFactory:
     @staticmethod
     def for_channel(
         channel_id: str,
-        choices: List[Choice],
+        choices: List[Union[str, Choice]],
         text: str = None,
         speak: str = None,
         options: ChoiceFactoryOptions = None,
@@ -36,8 +36,7 @@ def for_channel(
         if channel_id is None:
             channel_id = ""
 
-        if choices is None:
-            choices = []
+        choices = ChoiceFactory._to_choices(choices)
 
         # Find maximum title length
         max_title_length = 0
@@ -74,7 +73,7 @@ def for_channel(
 
     @staticmethod
     def inline(
-        choices: List[Choice],
+        choices: List[Union[str, Choice]],
         text: str = None,
         speak: str = None,
         options: ChoiceFactoryOptions = None,
@@ -89,8 +88,7 @@ def inline(
         speak: (Optional) SSML. Text to be spoken by your bot on a speech-enabled channel.
         options: (Optional) The formatting options to use to tweak rendering of list.
         """
-        if choices is None:
-            choices = []
+        choices = ChoiceFactory._to_choices(choices)
 
         if options is None:
             options = ChoiceFactoryOptions()
@@ -134,7 +132,7 @@ def inline(
 
     @staticmethod
     def list_style(
-        choices: List[Choice],
+        choices: List[Union[str, Choice]],
         text: str = None,
         speak: str = None,
         options: ChoiceFactoryOptions = None,
@@ -153,8 +151,7 @@ def list_style(
 
         options: (Optional) The formatting options to use to tweak rendering of list.
         """
-        if choices is None:
-            choices = []
+        choices = ChoiceFactory._to_choices(choices)
         if options is None:
             options = ChoiceFactoryOptions()
 
@@ -206,7 +203,7 @@ def suggested_action(
 
     @staticmethod
     def hero_card(
-        choices: List[Choice], text: str = None, speak: str = None
+        choices: List[Union[Choice, str]], text: str = None, speak: str = None
     ) -> Activity:
         """
         Creates a message activity that includes a lsit of coices that have been added as `HeroCard`'s
@@ -221,18 +218,22 @@ def hero_card(
         )
 
     @staticmethod
-    def _to_choices(choices: List[str]) -> List[Choice]:
+    def _to_choices(choices: List[Union[str, Choice]]) -> List[Choice]:
         """
         Takes a list of strings and returns them as [`Choice`].
         """
         if choices is None:
             return []
-        return [Choice(value=choice.value) for choice in choices]
+        return [
+            Choice(value=choice) if isinstance(choice, str) else choice
+            for choice in choices
+        ]
 
     @staticmethod
-    def _extract_actions(choices: List[Choice]) -> List[CardAction]:
+    def _extract_actions(choices: List[Union[str, Choice]]) -> List[CardAction]:
         if choices is None:
             choices = []
+        choices = ChoiceFactory._to_choices(choices)
         card_actions: List[CardAction] = []
         for choice in choices:
             if choice.action is not None:
diff --git a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py
--- a/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py
+++ b/libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/prompt.py
@@ -217,8 +217,11 @@ def default() -> Activity:
             ):
                 prompt.suggested_actions = msg.suggested_actions
 
-            if msg.attachments is not None and msg.attachments:
-                prompt.attachments = msg.attachments
+            if msg.attachments:
+                if prompt.attachments:
+                    prompt.attachments.extend(msg.attachments)
+                else:
+                    prompt.attachments = msg.attachments
 
             return prompt
 


Test patch:
diff --git a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py
--- a/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py
+++ b/libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py
@@ -352,7 +352,7 @@ async def assert_reply(
         :param timeout:
         :return:
         """
-
+        # TODO: refactor method so expected can take a Callable[[Activity], None]
         def default_inspector(reply, description=None):
             if isinstance(expected, Activity):
                 validate_activity(reply, expected)
diff --git a/libraries/botbuilder-dialogs/tests/test_choice_prompt.py b/libraries/botbuilder-dialogs/tests/test_choice_prompt.py
--- a/libraries/botbuilder-dialogs/tests/test_choice_prompt.py
+++ b/libraries/botbuilder-dialogs/tests/test_choice_prompt.py
@@ -6,7 +6,7 @@
 import aiounittest
 from recognizers_text import Culture
 
-from botbuilder.core import ConversationState, MemoryStorage, TurnContext
+from botbuilder.core import CardFactory, ConversationState, MemoryStorage, TurnContext
 from botbuilder.core.adapters import TestAdapter
 from botbuilder.dialogs import DialogSet, DialogTurnResult, DialogTurnStatus
 from botbuilder.dialogs.choices import Choice, ListStyle
@@ -588,3 +588,108 @@ async def exec_test(turn_context: TurnContext):
         )
         step3 = await step2.send("1")
         await step3.assert_reply("red")
+
+    async def test_should_display_choices_on_hero_card(self):
+        size_choices = ["large", "medium", "small"]
+
+        async def exec_test(turn_context: TurnContext):
+            dialog_context = await dialogs.create_context(turn_context)
+
+            results: DialogTurnResult = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                options = PromptOptions(
+                    prompt=Activity(
+                        type=ActivityTypes.message, text="Please choose a size."
+                    ),
+                    choices=size_choices,
+                )
+                await dialog_context.prompt("prompt", options)
+            elif results.status == DialogTurnStatus.Complete:
+                selected_choice = results.result
+                await turn_context.send_activity(selected_choice.value)
+
+            await convo_state.save_changes(turn_context)
+
+        def assert_expected_activity(
+            activity: Activity, description
+        ):  # pylint: disable=unused-argument
+            assert len(activity.attachments) == 1
+            assert (
+                activity.attachments[0].content_type
+                == CardFactory.content_types.hero_card
+            )
+            assert activity.attachments[0].content.text == "Please choose a size."
+
+        adapter = TestAdapter(exec_test)
+
+        convo_state = ConversationState(MemoryStorage())
+        dialog_state = convo_state.create_property("dialogState")
+        dialogs = DialogSet(dialog_state)
+
+        choice_prompt = ChoicePrompt("prompt")
+
+        # Change the ListStyle of the prompt to ListStyle.none.
+        choice_prompt.style = ListStyle.hero_card
+
+        dialogs.add(choice_prompt)
+
+        step1 = await adapter.send("Hello")
+        step2 = await step1.assert_reply(assert_expected_activity)
+        step3 = await step2.send("1")
+        await step3.assert_reply(size_choices[0])
+
+    async def test_should_display_choices_on_hero_card_with_additional_attachment(self):
+        size_choices = ["large", "medium", "small"]
+        card = CardFactory.adaptive_card(
+            {
+                "type": "AdaptiveCard",
+                "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+                "version": "1.2",
+                "body": [],
+            }
+        )
+        card_activity = Activity(attachments=[card])
+
+        async def exec_test(turn_context: TurnContext):
+            dialog_context = await dialogs.create_context(turn_context)
+
+            results: DialogTurnResult = await dialog_context.continue_dialog()
+
+            if results.status == DialogTurnStatus.Empty:
+                options = PromptOptions(prompt=card_activity, choices=size_choices)
+                await dialog_context.prompt("prompt", options)
+            elif results.status == DialogTurnStatus.Complete:
+                selected_choice = results.result
+                await turn_context.send_activity(selected_choice.value)
+
+            await convo_state.save_changes(turn_context)
+
+        def assert_expected_activity(
+            activity: Activity, description
+        ):  # pylint: disable=unused-argument
+            assert len(activity.attachments) == 2
+            assert (
+                activity.attachments[0].content_type
+                == CardFactory.content_types.adaptive_card
+            )
+            assert (
+                activity.attachments[1].content_type
+                == CardFactory.content_types.hero_card
+            )
+
+        adapter = TestAdapter(exec_test)
+
+        convo_state = ConversationState(MemoryStorage())
+        dialog_state = convo_state.create_property("dialogState")
+        dialogs = DialogSet(dialog_state)
+
+        choice_prompt = ChoicePrompt("prompt")
+
+        # Change the ListStyle of the prompt to ListStyle.none.
+        choice_prompt.style = ListStyle.hero_card
+
+        dialogs.add(choice_prompt)
+
+        step1 = await adapter.send("Hello")
+        await step1.assert_reply(assert_expected_activity)