diff --git a/examples/cards/src/main.py b/examples/cards/src/main.py index efee8699..d16a457e 100644 --- a/examples/cards/src/main.py +++ b/examples/cards/src/main.py @@ -8,10 +8,8 @@ from microsoft_teams.api import AdaptiveCardInvokeActivity, MessageActivity, MessageActivityInput from microsoft_teams.api.models.adaptive_card import ( - AdaptiveCardActionErrorResponse, AdaptiveCardActionMessageResponse, ) -from microsoft_teams.api.models.error import HttpError, InnerHttpError from microsoft_teams.api.models.invoke_response import AdaptiveCardInvokeResponse from microsoft_teams.apps import ActivityContext, App from microsoft_teams.cards import ( @@ -20,6 +18,7 @@ ExecuteAction, NumberInput, OpenUrlAction, + SubmitActionData, TextBlock, ToggleInput, ) @@ -29,15 +28,35 @@ def create_basic_adaptive_card() -> AdaptiveCard: - """Create a basic adaptive card for testing.""" + """Create a basic adaptive card for testing - uses ExecuteAction with specific action routing.""" card = AdaptiveCard( schema="http://adaptivecards.io/schemas/adaptive-card.json", body=[ - TextBlock(text="Hello world", wrap=True, weight="Bolder"), + TextBlock(text="Specific Action Routing", wrap=True, weight="Bolder"), ToggleInput(label="Notify me").with_id("notify"), ActionSet( actions=[ - ExecuteAction(title="Submit").with_data({"action": "submit_basic"}).with_associated_inputs("auto") + ExecuteAction(title="Submit") + .with_data(SubmitActionData("submit_basic")) + .with_associated_inputs("auto") + ] + ), + ], + ) + return card + + +def create_generic_execute_card() -> AdaptiveCard: + """Create a card with ExecuteAction that uses global handler (no specific action routing).""" + card = AdaptiveCard( + schema="http://adaptivecards.io/schemas/adaptive-card.json", + body=[ + TextBlock(text="Global Handler (No Action)", wrap=True, weight="Bolder"), + TextBlock(text="This card doesn't have a specific action handler", wrap=True), + ToggleInput(label="Enable feature").with_id("enabled"), + ActionSet( + actions=[ + ExecuteAction(title="Submit").with_data({"some_field": "some_value"}).with_associated_inputs("auto") ] ), ], @@ -110,7 +129,7 @@ def create_profile_card() -> AdaptiveCard: ActionSet( actions=[ ExecuteAction(title="Save") - .with_data({"action": "save_profile", "entity_id": "12345"}) + .with_data(SubmitActionData("save_profile", {"entity_id": "12345"})) .with_associated_inputs("auto"), OpenUrlAction(url="https://adaptivecards.microsoft.com").with_title("Learn More"), ] @@ -156,7 +175,7 @@ def create_feedback_card() -> AdaptiveCard: ActionSet( actions=[ ExecuteAction(title="Submit Feedback") - .with_data({"action": "submit_feedback"}) + .with_data(SubmitActionData("submit_feedback")) .with_associated_inputs("auto") ] ), @@ -167,12 +186,20 @@ def create_feedback_card() -> AdaptiveCard: @app.on_message_pattern("card") async def handle_card_message(ctx: ActivityContext[MessageActivity]): - """Handle card request messages.""" - print(f"[CARD] Card requested by: {ctx.activity.from_}") + """Handle card request messages - specific action routing.""" + print(f"[CARD] Card with specific action routing requested by: {ctx.activity.from_}") card = create_basic_adaptive_card() await ctx.send(card) +@app.on_message_pattern("generic") +async def handle_generic_card_message(ctx: ActivityContext[MessageActivity]): + """Handle generic card request messages - global handler.""" + print(f"[GENERIC] Card with global handler requested by: {ctx.activity.from_}") + card = create_generic_execute_card() + await ctx.send(card) + + @app.on_message_pattern("json") async def handle_validate_card_message(ctx: ActivityContext[MessageActivity]): """Handle model validation card request messages.""" @@ -207,7 +234,7 @@ async def handle_form(ctx: ActivityContext[MessageActivity]): ActionSet( actions=[ ExecuteAction(title="Create Task") - .with_data({"action": "create_task"}) + .with_data(SubmitActionData("create_task")) .with_associated_inputs("auto") .with_style("positive") ] @@ -242,69 +269,84 @@ async def handle_feedback_card(ctx: ActivityContext[MessageActivity]): await ctx.send(card) -@app.on_card_action -async def handle_form_action(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: - """Handle card action submissions from form example.""" +@app.on_card_action_execute +async def handle_all_execute_actions(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + """Handle all Action.Execute events without specific action routing (global handler).""" data = ctx.activity.value.action.data - if not data.get("action"): - print(ctx.activity) - return AdaptiveCardActionErrorResponse( - status_code=400, - type="application/vnd.microsoft.error", - value=HttpError( - code="BadRequest", - message="No action specified", - inner_http_error=InnerHttpError( - status_code=400, - body={"error": "No action specified"}, - ), - ), - ) - - print("Received action data:", data) - - if data["action"] == "submit_basic": - notify_value = data.get("notify", "false") - await ctx.send(f"Basic card submitted! Notify setting: {notify_value}") - elif data["action"] == "submit_feedback": - feedback_text = data.get("feedback", "No feedback provided") - await ctx.send(f"Feedback received: {feedback_text}") - elif data["action"] == "create_task": - title = data.get("title", "Untitled") - priority = data.get("priority", "medium") - due_date = data.get("due_date", "No date") - await ctx.send(f"Task created!\nTitle: {title}\nPriority: {priority}\nDue: {due_date}") - elif data["action"] == "save_profile": - entity_id = data.get("entity_id") - name = data.get("name", "Unknown") - email = data.get("email", "No email") - subscribe = data.get("subscribe", "false") - age = data.get("age") - location = data.get("location", "Not specified") - - response_text = f"Profile saved!\nName: {name}\nEmail: {email}\nSubscribed: {subscribe}" - if entity_id: - response_text += f"\nEntity ID: {entity_id}" - if age: - response_text += f"\nAge: {age}" - if location != "Not specified": - response_text += f"\nLocation: {location}" - - await ctx.send(response_text) - else: - return AdaptiveCardActionErrorResponse( - status_code=400, - type="application/vnd.microsoft.error", - value=HttpError( - code="BadRequest", - message="Unknown action", - inner_http_error=InnerHttpError( - status_code=400, - body={"error": "Unknown action"}, - ), - ), - ) + print(f"[GLOBAL HANDLER] Received Action.Execute data: {data}") + await ctx.send(f"Global handler processed Action.Execute. Data: {data}") + return AdaptiveCardActionMessageResponse( + status_code=200, + type="application/vnd.microsoft.activity.message", + value="Global handler processed action", + ) + +@app.on_card_action_execute("submit_basic") +async def handle_submit_basic(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + """Handle basic card submission - specific action routing.""" + data = ctx.activity.value.action.data + notify_value = data.get("notify", "false") + print(f"[SPECIFIC HANDLER] Received submit_basic action. Notify: {notify_value}") + await ctx.send(f"Specific handler: submit_basic. Notify setting: {notify_value}") + return AdaptiveCardActionMessageResponse( + status_code=200, + type="application/vnd.microsoft.activity.message", + value="Action processed successfully", + ) + + +@app.on_card_action_execute("submit_feedback") +async def handle_submit_feedback(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + """Handle feedback submission.""" + data = ctx.activity.value.action.data + print("Received submit_feedback action data:", data) + feedback_text = data.get("feedback", "No feedback provided") + await ctx.send(f"Feedback received: {feedback_text}") + return AdaptiveCardActionMessageResponse( + status_code=200, + type="application/vnd.microsoft.activity.message", + value="Action processed successfully", + ) + + +@app.on_card_action_execute("create_task") +async def handle_create_task(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + """Handle task creation.""" + data = ctx.activity.value.action.data + print("Received create_task action data:", data) + title = data.get("title", "Untitled") + priority = data.get("priority", "medium") + due_date = data.get("due_date", "No date") + await ctx.send(f"Task created!\nTitle: {title}\nPriority: {priority}\nDue: {due_date}") + return AdaptiveCardActionMessageResponse( + status_code=200, + type="application/vnd.microsoft.activity.message", + value="Action processed successfully", + ) + + +@app.on_card_action_execute("save_profile") +async def handle_save_profile(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + """Handle profile save.""" + data = ctx.activity.value.action.data + print("Received save_profile action data:", data) + entity_id = data.get("entity_id") + name = data.get("name", "Unknown") + email = data.get("email", "No email") + subscribe = data.get("subscribe", "false") + age = data.get("age") + location = data.get("location", "Not specified") + + response_text = f"Profile saved!\nName: {name}\nEmail: {email}\nSubscribed: {subscribe}" + if entity_id: + response_text += f"\nEntity ID: {entity_id}" + if age: + response_text += f"\nAge: {age}" + if location != "Not specified": + response_text += f"\nLocation: {location}" + + await ctx.send(response_text) return AdaptiveCardActionMessageResponse( status_code=200, type="application/vnd.microsoft.activity.message", diff --git a/examples/dialogs/src/main.py b/examples/dialogs/src/main.py index c3a10e5c..626a82c9 100644 --- a/examples/dialogs/src/main.py +++ b/examples/dialogs/src/main.py @@ -11,7 +11,6 @@ from microsoft_teams.api import ( AdaptiveCardAttachment, CardTaskModuleTaskInfo, - InvokeResponse, MessageActivity, MessageActivityInput, TaskFetchInvokeActivity, @@ -24,7 +23,14 @@ ) from microsoft_teams.apps import ActivityContext, App from microsoft_teams.apps.events.types import ErrorEvent -from microsoft_teams.cards import AdaptiveCard, SubmitAction, SubmitActionData, TaskFetchSubmitActionData, TextBlock +from microsoft_teams.cards import ( + AdaptiveCard, + OpenDialogData, + SubmitAction, + SubmitActionData, + TextBlock, + TextInput, +) from microsoft_teams.common.logging import ConsoleLogger logger_instance = ConsoleLogger() @@ -42,34 +48,15 @@ async def handle_message(ctx: ActivityContext[MessageActivity]) -> None: """Handle message activities and show dialog launcher card.""" - # Create the launcher adaptive card using Python objects to demonstrate SubmitActionData - # This tests that ms_teams correctly serializes to 'msteams' + # Create the launcher adaptive card with dialog buttons card = AdaptiveCard(version="1.4") card.body = [TextBlock(text="Select the examples you want to see!", size="Large", weight="Bolder")] - # Use SubmitActionData with ms_teams to test serialization - # SubmitActionData uses extra="allow" to accept custom fields - simple_form_data = SubmitActionData.model_validate({"opendialogtype": "simple_form"}) - simple_form_data.ms_teams = TaskFetchSubmitActionData().model_dump() - - webpage_data = SubmitActionData.model_validate({"opendialogtype": "webpage_dialog"}) - webpage_data.ms_teams = TaskFetchSubmitActionData().model_dump() - - multistep_data = SubmitActionData.model_validate({"opendialogtype": "multi_step_form"}) - multistep_data.ms_teams = TaskFetchSubmitActionData().model_dump() - + # Use OpenDialogData to create dialog open actions with clean API card.actions = [ - SubmitAction(title="Simple form test").with_data(simple_form_data), - SubmitAction(title="Webpage Dialog").with_data(webpage_data), - SubmitAction(title="Multi-step Form").with_data(multistep_data), - # Keep this one as JSON to show mixed usage - SubmitAction.model_validate( - { - "type": "Action.Submit", - "title": "Mixed Example (JSON)", - "data": {"msteams": {"type": "task/fetch"}, "opendialogtype": "mixed_example"}, - } - ), + SubmitAction(title="Simple form test").with_data(OpenDialogData("simple_form")), + SubmitAction(title="Webpage Dialog").with_data(OpenDialogData("webpage_dialog")), + SubmitAction(title="Multi-step Form").with_data(OpenDialogData("multi_step_form")), ] # Send the card as an attachment @@ -77,163 +64,134 @@ async def handle_message(ctx: ActivityContext[MessageActivity]) -> None: await ctx.send(message) -@app.on_dialog_open -async def handle_dialog_open(ctx: ActivityContext[TaskFetchInvokeActivity]): - """Handle dialog open events for all dialog types.""" - data: Optional[Any] = ctx.activity.value.data - dialog_type = data.get("opendialogtype") if data else None - - if dialog_type == "simple_form": - dialog_card = AdaptiveCard.model_validate( - { - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - {"type": "TextBlock", "text": "This is a simple form", "size": "Large", "weight": "Bolder"}, - { - "type": "Input.Text", - "id": "name", - "label": "Name", - "placeholder": "Enter your name", - "isRequired": True, - }, - ], - "actions": [ - {"type": "Action.Submit", "title": "Submit", "data": {"submissiondialogtype": "simple_form"}} - ], - } - ) - - return InvokeResponse( - body=TaskModuleResponse( - task=TaskModuleContinueResponse( - value=CardTaskModuleTaskInfo( - title="Simple Form Dialog", - card=card_attachment(AdaptiveCardAttachment(content=dialog_card)), - ) - ) +@app.on_dialog_open("simple_form") +async def handle_simple_form_open(ctx: ActivityContext[TaskFetchInvokeActivity]): + """Handle simple form dialog open.""" + dialog_card = AdaptiveCard.model_validate( + { + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + {"type": "TextBlock", "text": "This is a simple form", "size": "Large", "weight": "Bolder"}, + { + "type": "Input.Text", + "id": "name", + "label": "Name", + "placeholder": "Enter your name", + "isRequired": True, + }, + ], + "actions": [ + # Alternative: Use SubmitActionData for cleaner action-based routing + # SubmitAction(title="Submit").with_data(SubmitActionData("submit_simple_form")) + {"type": "Action.Submit", "title": "Submit", "data": {"action": "submit_simple_form"}} + ], + } + ) + + return TaskModuleResponse( + task=TaskModuleContinueResponse( + value=CardTaskModuleTaskInfo( + title="Simple Form Dialog", + card=card_attachment(AdaptiveCardAttachment(content=dialog_card)), ) ) - - elif dialog_type == "webpage_dialog": - return InvokeResponse( - body=TaskModuleResponse( - task=TaskModuleContinueResponse( - value=UrlTaskModuleTaskInfo( - title="Webpage Dialog", - url=f"{os.getenv('BOT_ENDPOINT', 'http://localhost:3978')}/tabs/dialog-form", - width=1000, - height=800, - ) - ) + ) + + +@app.on_dialog_open("webpage_dialog") +async def handle_webpage_dialog_open(ctx: ActivityContext[TaskFetchInvokeActivity]): + """Handle webpage dialog open.""" + return TaskModuleResponse( + task=TaskModuleContinueResponse( + value=UrlTaskModuleTaskInfo( + title="Webpage Dialog", + url=f"{os.getenv('BOT_ENDPOINT', 'http://localhost:3978')}/tabs/dialog-form", + width=1000, + height=800, ) ) - - elif dialog_type == "multi_step_form": - dialog_card = AdaptiveCard.model_validate( - { - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - {"type": "TextBlock", "text": "This is a multi-step form", "size": "Large", "weight": "Bolder"}, - { - "type": "Input.Text", - "id": "name", - "label": "Name", - "placeholder": "Enter your name", - "isRequired": True, - }, - ], - "actions": [ - { - "type": "Action.Submit", - "title": "Submit", - "data": {"submissiondialogtype": "webpage_dialog_step_1"}, - } - ], - } + ) + + +@app.on_dialog_open("multi_step_form") +async def handle_multi_step_form_open(ctx: ActivityContext[TaskFetchInvokeActivity]): + """Handle multi-step form dialog open.""" + dialog_card = ( + AdaptiveCard() + .with_body( + [ + TextBlock(text="This is a multi-step form", size="Large", weight="Bolder"), + TextInput(id="name").with_label("Name").with_placeholder("Enter your name").with_is_required(True), + ] ) - - return InvokeResponse( - body=TaskModuleResponse( - task=TaskModuleContinueResponse( - value=CardTaskModuleTaskInfo( - title="Multi-step Form Dialog", - card=card_attachment(AdaptiveCardAttachment(content=dialog_card)), - ) - ) + .with_actions([SubmitAction(title="Submit").with_data(SubmitActionData("submit_multi_step_1"))]) + ) + + return TaskModuleResponse( + task=TaskModuleContinueResponse( + value=CardTaskModuleTaskInfo( + title="Multi-step Form Dialog", + card=card_attachment(AdaptiveCardAttachment(content=dialog_card)), ) ) + ) + - # Default return for unknown dialog types - return TaskModuleResponse(task=TaskModuleMessageResponse(value="Unknown dialog type")) +@app.on_dialog_submit("submit_simple_form") +async def handle_simple_form_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]): + """Handle simple form submission.""" + data: Optional[Any] = ctx.activity.value.data + name = data.get("name") if data else None + await ctx.send(f"Hi {name}, thanks for submitting the form!") + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Form was submitted")) -@app.on_dialog_submit -async def handle_dialog_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]): - """Handle dialog submit events for all dialog types.""" +@app.on_dialog_submit("submit_webpage_dialog") +async def handle_webpage_dialog_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]): + """Handle webpage dialog submission.""" data: Optional[Any] = ctx.activity.value.data - dialog_type = data.get("submissiondialogtype") if data else None - - if dialog_type == "simple_form": - name = data.get("name") if data else None - await ctx.send(f"Hi {name}, thanks for submitting the form!") - return TaskModuleResponse(task=TaskModuleMessageResponse(value="Form was submitted")) - - elif dialog_type == "webpage_dialog": - name = data.get("name") if data else None - email = data.get("email") if data else None - await ctx.send(f"Hi {name}, thanks for submitting the form! We got that your email is {email}") - return InvokeResponse( - body=TaskModuleResponse(task=TaskModuleMessageResponse(value="Form submitted successfully")) - ) + name = data.get("name") if data else None + email = data.get("email") if data else None + await ctx.send(f"Hi {name}, thanks for submitting the form! We got that your email is {email}") + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Form submitted successfully")) - elif dialog_type == "webpage_dialog_step_1": - name = data.get("name") if data else None - next_step_card = AdaptiveCard.model_validate( - { - "type": "AdaptiveCard", - "version": "1.4", - "body": [ - {"type": "TextBlock", "text": "Email", "size": "Large", "weight": "Bolder"}, - { - "type": "Input.Text", - "id": "email", - "label": "Email", - "placeholder": "Enter your email", - "isRequired": True, - }, - ], - "actions": [ - { - "type": "Action.Submit", - "title": "Submit", - "data": {"submissiondialogtype": "webpage_dialog_step_2", "name": name}, - } - ], - } - ) - return InvokeResponse( - body=TaskModuleResponse( - task=TaskModuleContinueResponse( - value=CardTaskModuleTaskInfo( - title=f"Thanks {name} - Get Email", - card=card_attachment(AdaptiveCardAttachment(content=next_step_card)), - ) - ) +@app.on_dialog_submit("submit_multi_step_1") +async def handle_multi_step_1_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]): + """Handle multi-step form step 1 submission.""" + data: Optional[Any] = ctx.activity.value.data + name = data.get("name") if data else None + + next_step_card = ( + AdaptiveCard() + .with_body( + [ + TextBlock(text="Email", size="Large", weight="Bolder"), + TextInput(id="email").with_label("Email").with_placeholder("Enter your email").with_is_required(True), + ] + ) + .with_actions([SubmitAction(title="Submit").with_data(SubmitActionData("submit_multi_step_2", {"name": name}))]) + ) + + return TaskModuleResponse( + task=TaskModuleContinueResponse( + value=CardTaskModuleTaskInfo( + title=f"Thanks {name} - Get Email", + card=card_attachment(AdaptiveCardAttachment(content=next_step_card)), ) ) + ) - elif dialog_type == "webpage_dialog_step_2": - name = data.get("name") if data else None - email = data.get("email") if data else None - await ctx.send(f"Hi {name}, thanks for submitting the form! We got that your email is {email}") - return InvokeResponse( - body=TaskModuleResponse(task=TaskModuleMessageResponse(value="Multi-step form completed successfully")) - ) - return TaskModuleResponse(task=TaskModuleMessageResponse(value="Unknown submission type")) +@app.on_dialog_submit("submit_multi_step_2") +async def handle_multi_step_2_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]): + """Handle multi-step form step 2 submission.""" + data: Optional[Any] = ctx.activity.value.data + name = data.get("name") if data else None + email = data.get("email") if data else None + await ctx.send(f"Hi {name}, thanks for submitting the form! We got that your email is {email}") + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Multi-step form completed successfully")) @app.event("error") diff --git a/packages/apps/scripts/generate_handlers.py b/packages/apps/scripts/generate_handlers.py index 98946093..e3286260 100644 --- a/packages/apps/scripts/generate_handlers.py +++ b/packages/apps/scripts/generate_handlers.py @@ -13,7 +13,7 @@ # Import the activity config directly without going through the package hierarchy activity_config_path = ( - Path(__file__).parent.parent / "src" / "microsoft" / "teams" / "apps" / "routing" / "activity_route_configs.py" + Path(__file__).parent.parent / "src" / "microsoft_teams" / "apps" / "routing" / "activity_route_configs.py" ) # Load the activity config module directly because we don't want to have a dependency on the package @@ -190,7 +190,7 @@ def generate_activity_handlers(): # Write to the message_handler directory in the source code # Use Path(__file__) to find this script's location, then navigate to the target script_dir = Path(__file__).parent - source_dir = script_dir.parent / "src" / "microsoft" / "teams" / "apps" / "routing" + source_dir = script_dir.parent / "src" / "microsoft_teams" / "apps" / "routing" output_path = source_dir / "generated_handlers.py" # Ensure the target directory exists diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_handlers.py b/packages/apps/src/microsoft_teams/apps/routing/activity_handlers.py index 280ff949..85ad183a 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_handlers.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_handlers.py @@ -5,16 +5,22 @@ from abc import ABC, abstractmethod from logging import Logger -from typing import Awaitable, Callable, Optional, Pattern, Union, overload +from typing import Any, Awaitable, Callable, Dict, Optional, Pattern, Union, cast, overload from microsoft_teams.api import ( ActivityBase, + AdaptiveCardInvokeActivity, + AdaptiveCardInvokeResponse, MessageActivity, + TaskFetchInvokeActivity, + TaskModuleInvokeResponse, + TaskSubmitInvokeActivity, ) from .activity_context import ActivityContext from .generated_handlers import GeneratedActivityHandlerMixin from .router import ActivityRouter +from .type_helpers import InvokeHandler, InvokeHandlerUnion from .type_validation import validate_handler_type @@ -119,3 +125,445 @@ def selector(ctx: ActivityBase) -> bool: if handler is not None: return decorator(handler) return decorator + + @overload + def on_dialog_open( + self, + ) -> Callable[ + [InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]], + InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse], + ]: + """ + Register a global dialog open handler for all dialog open events. + + Usage: + + @app.on_dialog_open + async def handle_all_dialogs(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleInvokeResponse: + return InvokeResponse(...) + + """ + ... + + @overload + def on_dialog_open( + self, + dialog_id_or_handler: InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse], + ) -> InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]: + """ + Register a global dialog open handler for all dialog open events. + + Usage: + + async def handle_all_dialogs(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleInvokeResponse: + return InvokeResponse(...) + app.on_dialog_open(handle_all_dialogs) + + """ + ... + + @overload + def on_dialog_open( + self, dialog_id_or_handler: str + ) -> Callable[ + [InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]], + InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse], + ]: + """ + Register a dialog open handler that matches a specific dialog_id. + + Args: + dialog_id_or_handler: The dialog identifier to match against the 'dialog_id' field in activity data + + Usage: + + @app.on_dialog_open("simple_form") + async def handle_simple_form_open( + ctx: ActivityContext[TaskFetchInvokeActivity] + ) -> TaskModuleInvokeResponse: + return InvokeResponse(...) + + """ + ... + + @overload + def on_dialog_open( + self, + dialog_id_or_handler: str, + handler: InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse], + ) -> InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]: + """ + Register a dialog open handler that matches a specific dialog_id. + + Args: + dialog_id_or_handler: The dialog identifier to match against the 'dialog_id' field in activity data + handler: The async function to call when the dialog_id matches + + Usage: + + async def handle_simple_form_open( + ctx: ActivityContext[TaskFetchInvokeActivity] + ) -> TaskModuleInvokeResponse: + return InvokeResponse(...) + app.on_dialog_open("simple_form", handle_simple_form_open) + + """ + ... + + def on_dialog_open( + self, + dialog_id_or_handler: Union[str, InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse], None] = None, + handler: Optional[InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]] = None, + ) -> InvokeHandlerUnion[TaskFetchInvokeActivity, TaskModuleInvokeResponse]: + """ + Register a dialog open handler. + + Args: + dialog_id_or_handler: Optional dialog identifier to match against the 'dialog_id' field in activity data, + or a handler function to match all dialog open events. + handler: The async function to call when the event matches + + Returns: + Decorated function or decorator + """ + + # Handle case where first argument is actually a handler function (no dialog_id) + if callable(dialog_id_or_handler): + handler = dialog_id_or_handler + dialog_id_or_handler = None + + def decorator( + func: InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse], + ) -> InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]: + validate_handler_type( + self.logger, func, TaskFetchInvokeActivity, "on_dialog_open", "TaskFetchInvokeActivity" + ) + + def selector(ctx: ActivityBase) -> bool: + if not isinstance(ctx, TaskFetchInvokeActivity): + return False + # If no dialog_id specified, match all dialog open events + if dialog_id_or_handler is None: + return True + # Otherwise, match specific dialog_id + data = ctx.value.data if ctx.value else None + if not isinstance(data, dict): + return False + data = cast(Dict[str, Any], data) + dialog_id = data.get("dialog_id") + if dialog_id is not None and not isinstance(dialog_id, str): + self.logger.warning( + f"Expected 'dialog_id' to be a string, got {type(dialog_id).__name__}: {dialog_id}" + ) + return False + return dialog_id == dialog_id_or_handler + + self.router.add_handler(selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_dialog_submit( + self, + ) -> Callable[ + [InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]], + InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse], + ]: + """ + Register a global dialog submit handler for all dialog submit events. + + Usage: + + @app.on_dialog_submit + async def handle_all_submits(ctx: ActivityContext[TaskSubmitInvokeActivity]) -> TaskModuleInvokeResponse: + return InvokeResponse(...) + + """ + ... + + @overload + def on_dialog_submit( + self, + action_or_handler: InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse], + ) -> InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]: + """ + Register a global dialog submit handler for all dialog submit events. + + Usage: + + async def handle_all_submits(ctx: ActivityContext[TaskSubmitInvokeActivity]) -> TaskModuleInvokeResponse: + return InvokeResponse(...) + app.on_dialog_submit(handle_all_submits) + + """ + ... + + @overload + def on_dialog_submit( + self, action_or_handler: str + ) -> Callable[ + [InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]], + InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse], + ]: + """ + Register a dialog submit handler that matches a specific action. + + Args: + action_or_handler: The action identifier to match against the 'action' field in activity data + + Usage: + + @app.on_dialog_submit("submit_user_form") + async def handle_user_form_submit( + ctx: ActivityContext[TaskSubmitInvokeActivity] + ) -> TaskModuleInvokeResponse: + return InvokeResponse(...) + + """ + ... + + @overload + def on_dialog_submit( + self, + action_or_handler: str, + handler: InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse], + ) -> InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]: + """ + Register a dialog submit handler that matches a specific action. + + Args: + action_or_handler: The action identifier to match against the 'action' field in activity data + handler: The async function to call when the action matches + + Usage: + + async def handle_user_form_submit( + ctx: ActivityContext[TaskSubmitInvokeActivity] + ) -> TaskModuleInvokeResponse: + return InvokeResponse(...) + app.on_dialog_submit("submit_user_form", handle_user_form_submit) + + """ + ... + + def on_dialog_submit( + self, + action_or_handler: Union[str, InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse], None] = None, + handler: Optional[InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]] = None, + ) -> InvokeHandlerUnion[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]: + """ + Register a dialog submit handler. + + Args: + action_or_handler: Optional action identifier to match against the 'action' field in activity data, + or a handler function to match all dialog submit events. + handler: The async function to call when the event matches + + Returns: + Decorated function or decorator + """ + + # Handle case where first argument is actually a handler function (no action) + if callable(action_or_handler): + handler = action_or_handler + action_or_handler = None + + def decorator( + func: InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse], + ) -> InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]: + validate_handler_type( + self.logger, func, TaskSubmitInvokeActivity, "on_dialog_submit", "TaskSubmitInvokeActivity" + ) + + def selector(ctx: ActivityBase) -> bool: + if not isinstance(ctx, TaskSubmitInvokeActivity): + return False + # If no action specified, match all dialog submit events + if action_or_handler is None: + return True + # Otherwise, match specific action + data = ctx.value.data if ctx.value else None + if not isinstance(data, dict): + return False + data = cast(Dict[str, Any], data) + action = data.get("action") + if action is not None and not isinstance(action, str): + self.logger.warning(f"Expected 'action' to be a string, got {type(action).__name__}: {action}") + return False + return action == action_or_handler + + self.router.add_handler(selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + + @overload + def on_card_action_execute( + self, + ) -> Callable[ + [InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]], + InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ]: + """ + Register a global handler for all Action.Execute card actions. + + Usage: + + @app.on_card_action_execute + async def handle_all_execute_actions( + ctx: ActivityContext[AdaptiveCardInvokeActivity], + ) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse(...) + + """ + ... + + @overload + def on_card_action_execute( + self, + action_or_handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: + """ + Register a global handler for all Action.Execute card actions. + + Usage: + + async def handle_all_execute_actions( + ctx: ActivityContext[AdaptiveCardInvokeActivity], + ) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse(...) + app.on_card_action_execute(handle_all_execute_actions) + + """ + ... + + @overload + def on_card_action_execute( + self, action_or_handler: str + ) -> Callable[ + [InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]], + InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ]: + """ + Register a handler for Action.Execute card actions with a specific action identifier. + + Only Action.Execute is supported. Action.Submit and other action types do not trigger + AdaptiveCardInvokeActivity in modern Teams clients. + + Args: + action_or_handler: The action identifier to match against the 'action' field in activity data + + Usage: + + @app.on_card_action_execute("submit_basic") + async def handle_basic_submit( + ctx: ActivityContext[AdaptiveCardInvokeActivity] + ) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse(...) + + """ + ... + + @overload + def on_card_action_execute( + self, + action_or_handler: str, + handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: + """ + Register a handler for Action.Execute card actions with a specific action identifier. + + Only Action.Execute is supported. Action.Submit and other action types do not trigger + AdaptiveCardInvokeActivity in modern Teams clients. + + Args: + action_or_handler: The action identifier to match against the 'action' field in activity data + handler: The async function to call when the action matches + + Usage: + + async def handle_basic_submit( + ctx: ActivityContext[AdaptiveCardInvokeActivity] + ) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse(...) + app.on_card_action_execute("submit_basic", handle_basic_submit) + + """ + ... + + def on_card_action_execute( + self, + action_or_handler: Union[ + str, InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], None + ] = None, + handler: Optional[InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]] = None, + ) -> InvokeHandlerUnion[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: + """ + Register a handler for Action.Execute card actions. + + This handler provides action-based routing for ExecuteAction (Action.Execute) buttons. + Only Action.Execute is supported - Action.Submit and other action types do not trigger + AdaptiveCardInvokeActivity in modern Teams clients. + + Args: + action_or_handler: Optional action identifier to match against the 'action' field in activity data, + or a handler function to match all Action.Execute events. + handler: The async function to call when the event matches + + Returns: + Decorated function or decorator + """ + + # Handle case where first argument is actually a handler function (no action) + if callable(action_or_handler): + handler = action_or_handler + action_or_handler = None + + def decorator( + func: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: + validate_handler_type( + self.logger, + func, + AdaptiveCardInvokeActivity, + "on_card_action_execute", + "AdaptiveCardInvokeActivity", + ) + + def selector(ctx: ActivityBase) -> bool: + if not isinstance(ctx, AdaptiveCardInvokeActivity): + return False + + # If no action specified, match all Action.Execute events + if action_or_handler is None: + # Still validate it's Action.Execute for global handler + if not ctx.value or not ctx.value.action: + return False + if ctx.value.action.type != "Action.Execute": + return False + return True + + # Otherwise, match specific action with Action.Execute validation + if not ctx.value or not ctx.value.action: + return False + + # Extract and match action field + data = ctx.value.action.data + action = data.get("action") + if action is not None and not isinstance(action, str): + self.logger.warning(f"Expected 'action' to be a string, got {type(action).__name__}: {action}") + return False + + return action == action_or_handler + + self.router.add_handler(selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator 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..db4bb5db 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 @@ -49,9 +49,6 @@ TabFetchInvokeActivity, TabInvokeResponse, TabSubmitInvokeActivity, - TaskFetchInvokeActivity, - TaskModuleInvokeResponse, - TaskSubmitInvokeActivity, TraceActivity, TypingActivity, UninstalledActivity, @@ -423,24 +420,26 @@ class ActivityConfig: output_model=None, is_invoke=True, ), - "dialog.open": ActivityConfig( - name="dialog.open", - method_name="on_dialog_open", - input_model=TaskFetchInvokeActivity, - selector=lambda activity: activity.type == "invoke" and cast(InvokeActivity, activity).name == "task/fetch", - output_model=TaskModuleInvokeResponse, - output_type_name="TaskModuleInvokeResponse", - is_invoke=True, - ), - "dialog.submit": ActivityConfig( - name="dialog.submit", - method_name="on_dialog_submit", - input_model=TaskSubmitInvokeActivity, - selector=lambda activity: activity.type == "invoke" and cast(InvokeActivity, activity).name == "task/submit", - output_model=TaskModuleInvokeResponse, - output_type_name="TaskModuleInvokeResponse", - is_invoke=True, - ), + # Note: dialog.open and dialog.submit are manually implemented in activity_handlers.py + # They have overloaded versions that accept dialog_id/action parameters for routing + # "dialog.open": ActivityConfig( + # name="dialog.open", + # method_name="on_dialog_open", + # input_model=TaskFetchInvokeActivity, + # selector=lambda activity: activity.type == "invoke" and cast(InvokeActivity, activity).name == "task/fetch", + # output_model=TaskModuleInvokeResponse, + # output_type_name="TaskModuleInvokeResponse", + # is_invoke=True, + # ), + # "dialog.submit": ActivityConfig( + # name="dialog.submit", + # method_name="on_dialog_submit", + # input_model=TaskSubmitInvokeActivity, + # selector=lambda activity: activity.type == "invoke" and cast(InvokeActivity, activity).name == "task/submit", + # output_model=TaskModuleInvokeResponse, + # output_type_name="TaskModuleInvokeResponse", + # is_invoke=True, + # ), "tab.open": ActivityConfig( name="tab.open", method_name="on_tab_open", diff --git a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py index e64833b9..b47cfe01 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py +++ b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py @@ -53,8 +53,6 @@ SignInVerifyStateInvokeActivity, TabFetchInvokeActivity, TabSubmitInvokeActivity, - TaskFetchInvokeActivity, - TaskSubmitInvokeActivity, TraceActivity, TypingActivity, UninstalledActivity, @@ -65,7 +63,6 @@ MessagingExtensionActionInvokeResponse, MessagingExtensionInvokeResponse, TabInvokeResponse, - TaskModuleInvokeResponse, TokenExchangeInvokeResponseType, ) @@ -1284,70 +1281,6 @@ def decorator( return decorator(handler) return decorator - @overload - def on_dialog_open( - self, handler: InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse] - ) -> InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]: ... - - @overload - def on_dialog_open( - self, - ) -> Callable[ - [InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]], - InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse], - ]: ... - - def on_dialog_open( - self, handler: Optional[InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]] = None - ) -> InvokeHandlerUnion[TaskFetchInvokeActivity, TaskModuleInvokeResponse]: - """Register a dialog.open activity handler.""" - - def decorator( - func: InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse], - ) -> InvokeHandler[TaskFetchInvokeActivity, TaskModuleInvokeResponse]: - validate_handler_type( - self.logger, func, TaskFetchInvokeActivity, "on_dialog_open", "TaskFetchInvokeActivity" - ) - config = ACTIVITY_ROUTES["dialog.open"] - self.router.add_handler(config.selector, func) - return func - - if handler is not None: - return decorator(handler) - return decorator - - @overload - def on_dialog_submit( - self, handler: InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse] - ) -> InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]: ... - - @overload - def on_dialog_submit( - self, - ) -> Callable[ - [InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]], - InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse], - ]: ... - - def on_dialog_submit( - self, handler: Optional[InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]] = None - ) -> InvokeHandlerUnion[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]: - """Register a dialog.submit activity handler.""" - - def decorator( - func: InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse], - ) -> InvokeHandler[TaskSubmitInvokeActivity, TaskModuleInvokeResponse]: - validate_handler_type( - self.logger, func, TaskSubmitInvokeActivity, "on_dialog_submit", "TaskSubmitInvokeActivity" - ) - config = ACTIVITY_ROUTES["dialog.submit"] - self.router.add_handler(config.selector, func) - return func - - if handler is not None: - return decorator(handler) - return decorator - @overload def on_tab_open( self, handler: InvokeHandler[TabFetchInvokeActivity, TabInvokeResponse] diff --git a/packages/apps/tests/test_card_action_routing.py b/packages/apps/tests/test_card_action_routing.py new file mode 100644 index 00000000..ae139f6a --- /dev/null +++ b/packages/apps/tests/test_card_action_routing.py @@ -0,0 +1,289 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from unittest.mock import MagicMock + +import pytest +from microsoft_teams.api import ( + Account, + AdaptiveCardInvokeActivity, + AdaptiveCardInvokeResponse, + ConversationAccount, +) +from microsoft_teams.api.models.adaptive_card import ( + AdaptiveCardActionMessageResponse, + AdaptiveCardInvokeAction, + AdaptiveCardInvokeValue, +) +from microsoft_teams.apps import ActivityContext, App + + +class TestCardActionExecuteRouting: + """Test cases for card action execute routing functionality.""" + + @pytest.fixture + def mock_logger(self): + """Create a mock logger.""" + return MagicMock() + + @pytest.fixture + def mock_storage(self): + """Create a mock storage.""" + return MagicMock() + + @pytest.fixture(scope="function") + def app_with_options(self, mock_logger, mock_storage): + """Create an app with basic options.""" + return App( + logger=mock_logger, + storage=mock_storage, + client_id="test-client-id", + client_secret="test-secret", + ) + + def test_on_card_action_execute_with_action_id(self, app_with_options: App) -> None: + """Test on_card_action_execute with specific action matching.""" + + @app_with_options.on_card_action_execute("submit_form") + async def handle_submit_form(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse( + status_code=200, type="application/vnd.microsoft.activity.message", value="Form submitted" + ) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + # Test matching action + matching_activity = AdaptiveCardInvokeActivity( + id="test-activity-id", + type="invoke", + name="adaptiveCard/action", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=AdaptiveCardInvokeValue( + action=AdaptiveCardInvokeAction(type="Action.Execute", data={"action": "submit_form"}) + ), + ) + + # Test non-matching action + non_matching_activity = AdaptiveCardInvokeActivity( + id="test-activity-id-2", + type="invoke", + name="adaptiveCard/action", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=AdaptiveCardInvokeValue( + action=AdaptiveCardInvokeAction(type="Action.Execute", data={"action": "other_action"}) + ), + ) + + # Verify handler was registered and can match + handlers = app_with_options.router.select_handlers(matching_activity) + assert len(handlers) == 1 + assert handlers[0] == handle_submit_form + + # Verify non-matching action doesn't match + non_matching_handlers = app_with_options.router.select_handlers(non_matching_activity) + assert len(non_matching_handlers) == 0 + + def test_on_card_action_execute_global_handler(self, app_with_options: App) -> None: + """Test on_card_action_execute without action matches all Action.Execute actions.""" + + @app_with_options.on_card_action_execute() + async def handle_all_actions(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse( + status_code=200, type="application/vnd.microsoft.activity.message", value="Action received" + ) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + # Test with any action + activity1 = AdaptiveCardInvokeActivity( + id="test-activity-id-1", + type="invoke", + name="adaptiveCard/action", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=AdaptiveCardInvokeValue( + action=AdaptiveCardInvokeAction(type="Action.Execute", data={"action": "action1"}) + ), + ) + + activity2 = AdaptiveCardInvokeActivity( + id="test-activity-id-2", + type="invoke", + name="adaptiveCard/action", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=AdaptiveCardInvokeValue( + action=AdaptiveCardInvokeAction(type="Action.Execute", data={"action": "action2"}) + ), + ) + + # Both should match the global handler + handlers1 = app_with_options.router.select_handlers(activity1) + assert len(handlers1) == 1 + assert handlers1[0] == handle_all_actions + + handlers2 = app_with_options.router.select_handlers(activity2) + assert len(handlers2) == 1 + assert handlers2[0] == handle_all_actions + + def test_on_card_action_execute_multiple_specific_handlers(self, app_with_options: App) -> None: + """Test multiple specific action handlers coexist correctly.""" + + @app_with_options.on_card_action_execute("submit_form") + async def handle_submit_form(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse( + status_code=200, type="application/vnd.microsoft.activity.message", value="Form submitted" + ) + + @app_with_options.on_card_action_execute("save_data") + async def handle_save_data(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse( + status_code=200, type="application/vnd.microsoft.activity.message", value="Data saved" + ) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + submit_activity = AdaptiveCardInvokeActivity( + id="test-activity-id-1", + type="invoke", + name="adaptiveCard/action", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=AdaptiveCardInvokeValue( + action=AdaptiveCardInvokeAction(type="Action.Execute", data={"action": "submit_form"}) + ), + ) + + save_activity = AdaptiveCardInvokeActivity( + id="test-activity-id-2", + type="invoke", + name="adaptiveCard/action", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=AdaptiveCardInvokeValue( + action=AdaptiveCardInvokeAction(type="Action.Execute", data={"action": "save_data"}) + ), + ) + + # Each should match only its specific handler + submit_handlers = app_with_options.router.select_handlers(submit_activity) + assert len(submit_handlers) == 1 + assert submit_handlers[0] == handle_submit_form + + save_handlers = app_with_options.router.select_handlers(save_activity) + assert len(save_handlers) == 1 + assert save_handlers[0] == handle_save_data + + def test_on_card_action_execute_decorator_syntax(self, app_with_options: App) -> None: + """Test on_card_action_execute works with decorator syntax.""" + + @app_with_options.on_card_action_execute("test_action") + async def decorated_handler(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse( + status_code=200, type="application/vnd.microsoft.activity.message", value="Decorated" + ) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + activity = AdaptiveCardInvokeActivity( + id="test-activity-id", + type="invoke", + name="adaptiveCard/action", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=AdaptiveCardInvokeValue( + action=AdaptiveCardInvokeAction(type="Action.Execute", data={"action": "test_action"}) + ), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 1 + assert handlers[0] == decorated_handler + + def test_on_card_action_execute_non_decorator_syntax(self, app_with_options: App) -> None: + """Test on_card_action_execute works with non-decorator syntax.""" + + async def handler_function(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse( + status_code=200, type="application/vnd.microsoft.activity.message", value="Non-decorated" + ) + + app_with_options.on_card_action_execute("non_decorated_action", handler_function) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + activity = AdaptiveCardInvokeActivity( + id="test-activity-id", + type="invoke", + name="adaptiveCard/action", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=AdaptiveCardInvokeValue( + action=AdaptiveCardInvokeAction(type="Action.Execute", data={"action": "non_decorated_action"}) + ), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 1 + assert handlers[0] == handler_function + + def test_on_card_action_execute_missing_action_field(self, app_with_options: App) -> None: + """Test on_card_action_execute handler doesn't match when action field is missing.""" + + @app_with_options.on_card_action_execute("submit_form") + async def handle_submit_form(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + return AdaptiveCardActionMessageResponse( + status_code=200, type="application/vnd.microsoft.activity.message", value="Form submitted" + ) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + # Activity with no action field in data + activity = AdaptiveCardInvokeActivity( + id="test-activity-id", + type="invoke", + name="adaptiveCard/action", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=AdaptiveCardInvokeValue( + action=AdaptiveCardInvokeAction(type="Action.Execute", data={"other_field": "value"}) + ), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 0 diff --git a/packages/apps/tests/test_dialog_routing.py b/packages/apps/tests/test_dialog_routing.py new file mode 100644 index 00000000..b59bcbdd --- /dev/null +++ b/packages/apps/tests/test_dialog_routing.py @@ -0,0 +1,471 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from unittest.mock import MagicMock + +import pytest +from microsoft_teams.api import ( + Account, + AdaptiveCardAttachment, + CardTaskModuleTaskInfo, + ConversationAccount, + InvokeResponse, + TaskFetchInvokeActivity, + TaskModuleContinueResponse, + TaskModuleMessageResponse, + TaskModuleRequest, + TaskModuleResponse, + TaskSubmitInvokeActivity, + card_attachment, +) +from microsoft_teams.apps import ActivityContext, App +from microsoft_teams.cards import AdaptiveCard + + +class TestDialogRouting: + """Test cases for dialog routing functionality.""" + + @pytest.fixture + def mock_logger(self): + """Create a mock logger.""" + return MagicMock() + + @pytest.fixture + def mock_storage(self): + """Create a mock storage.""" + return MagicMock() + + @pytest.fixture(scope="function") + def app_with_options(self, mock_logger, mock_storage): + """Create an app with basic options.""" + return App( + logger=mock_logger, + storage=mock_storage, + client_id="test-client-id", + client_secret="test-secret", + ) + + def test_on_dialog_open_with_dialog_id(self, app_with_options: App) -> None: + """Test on_dialog_open with specific dialog_id matching.""" + + @app_with_options.on_dialog_open("test_dialog") + async def handle_test_dialog(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Test dialog opened")) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + # Test matching dialog_id + matching_activity = TaskFetchInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"dialog_id": "test_dialog"}), + ) + + # Test non-matching dialog_id + non_matching_activity = TaskFetchInvokeActivity( + id="test-activity-id-2", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"dialog_id": "other_dialog"}), + ) + + # Verify handler was registered and can match + handlers = app_with_options.router.select_handlers(matching_activity) + assert len(handlers) == 1 + assert handlers[0] == handle_test_dialog + + # Verify non-matching dialog_id doesn't match + non_matching_handlers = app_with_options.router.select_handlers(non_matching_activity) + assert len(non_matching_handlers) == 0 + + def test_on_dialog_open_global_handler(self, app_with_options: App) -> None: + """Test on_dialog_open without dialog_id matches all dialog opens.""" + + @app_with_options.on_dialog_open() + async def handle_all_dialogs(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Any dialog opened")) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + # Test with dialog_id present + activity_with_id = TaskFetchInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"dialog_id": "some_dialog"}), + ) + + # Test without dialog_id + activity_without_id = TaskFetchInvokeActivity( + id="test-activity-id-2", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={}), + ) + + # Both should match global handler + handlers_with_id = app_with_options.router.select_handlers(activity_with_id) + assert len(handlers_with_id) == 1 + assert handlers_with_id[0] == handle_all_dialogs + + handlers_without_id = app_with_options.router.select_handlers(activity_without_id) + assert len(handlers_without_id) == 1 + assert handlers_without_id[0] == handle_all_dialogs + + def test_on_dialog_open_with_non_dict_data(self, app_with_options: App) -> None: + """Test on_dialog_open handles non-dict data gracefully.""" + + @app_with_options.on_dialog_open("test_dialog") + async def handle_test_dialog(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Test")) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + # Test with non-dict data (should not match) + activity = TaskFetchInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data="not a dict"), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 0 + + def test_on_dialog_submit_with_action(self, app_with_options: App) -> None: + """Test on_dialog_submit with specific action matching.""" + + @app_with_options.on_dialog_submit("submit_form") + async def handle_form_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Form submitted")) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + # Test matching action + matching_activity = TaskSubmitInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/submit", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"action": "submit_form", "name": "John"}), + ) + + # Test non-matching action + non_matching_activity = TaskSubmitInvokeActivity( + id="test-activity-id-2", + type="invoke", + name="task/submit", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"action": "cancel_form"}), + ) + + # Verify handler was registered and can match + handlers = app_with_options.router.select_handlers(matching_activity) + assert len(handlers) == 1 + assert handlers[0] == handle_form_submit + + # Verify non-matching action doesn't match + non_matching_handlers = app_with_options.router.select_handlers(non_matching_activity) + assert len(non_matching_handlers) == 0 + + def test_on_dialog_submit_global_handler(self, app_with_options: App) -> None: + """Test on_dialog_submit without action matches all dialog submits.""" + + @app_with_options.on_dialog_submit() + async def handle_all_submits(ctx: ActivityContext[TaskSubmitInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Any submit")) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + # Test with action present + activity_with_action = TaskSubmitInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/submit", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"action": "some_action"}), + ) + + # Test without action + activity_without_action = TaskSubmitInvokeActivity( + id="test-activity-id-2", + type="invoke", + name="task/submit", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"name": "John"}), + ) + + # Both should match global handler + handlers_with_action = app_with_options.router.select_handlers(activity_with_action) + assert len(handlers_with_action) == 1 + assert handlers_with_action[0] == handle_all_submits + + handlers_without_action = app_with_options.router.select_handlers(activity_without_action) + assert len(handlers_without_action) == 1 + assert handlers_without_action[0] == handle_all_submits + + def test_on_dialog_open_non_decorator_syntax(self, app_with_options: App) -> None: + """Test on_dialog_open using non-decorator syntax.""" + + async def handle_dialog(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Dialog opened")) + + app_with_options.on_dialog_open("my_dialog", handle_dialog) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + activity = TaskFetchInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"dialog_id": "my_dialog"}), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 1 + assert handlers[0] == handle_dialog + + def test_on_dialog_submit_non_decorator_syntax(self, app_with_options: App) -> None: + """Test on_dialog_submit using non-decorator syntax.""" + + async def handle_submit(ctx: ActivityContext[TaskSubmitInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Submitted")) + + app_with_options.on_dialog_submit("my_action", handle_submit) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + activity = TaskSubmitInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/submit", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"action": "my_action"}), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 1 + assert handlers[0] == handle_submit + + def test_on_dialog_open_handler_as_first_arg(self, app_with_options: App) -> None: + """Test on_dialog_open with handler as first argument (global handler).""" + + async def handle_all(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="All")) + + app_with_options.on_dialog_open(handle_all) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + activity = TaskFetchInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"dialog_id": "any"}), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 1 + assert handlers[0] == handle_all + + def test_on_dialog_submit_handler_as_first_arg(self, app_with_options: App) -> None: + """Test on_dialog_submit with handler as first argument (global handler).""" + + async def handle_all(ctx: ActivityContext[TaskSubmitInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="All")) + + app_with_options.on_dialog_submit(handle_all) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + activity = TaskSubmitInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/submit", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"action": "any"}), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 1 + assert handlers[0] == handle_all + + def test_multiple_dialog_handlers(self, app_with_options: App) -> None: + """Test multiple dialog handlers can coexist.""" + + @app_with_options.on_dialog_open("dialog_a") + async def handle_dialog_a(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Dialog A")) + + @app_with_options.on_dialog_open("dialog_b") + async def handle_dialog_b(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleResponse: + return TaskModuleResponse(task=TaskModuleMessageResponse(value="Dialog B")) + + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + activity_a = TaskFetchInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"dialog_id": "dialog_a"}), + ) + + activity_b = TaskFetchInvokeActivity( + id="test-activity-id-2", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"dialog_id": "dialog_b"}), + ) + + # Verify each handler only matches its specific dialog_id + handlers_a = app_with_options.router.select_handlers(activity_a) + assert len(handlers_a) == 1 + assert handlers_a[0] == handle_dialog_a + + handlers_b = app_with_options.router.select_handlers(activity_b) + assert len(handlers_b) == 1 + assert handlers_b[0] == handle_dialog_b + + def test_on_dialog_open_returns_unwrapped_response(self, app_with_options: App) -> None: + """Test that handlers can return TaskModuleResponse directly (unwrapped from InvokeResponse).""" + + @app_with_options.on_dialog_open("test_dialog") + async def handle_dialog(ctx: ActivityContext[TaskFetchInvokeActivity]) -> TaskModuleResponse: + # Return unwrapped TaskModuleResponse (not InvokeResponse[TaskModuleResponse]) + card = AdaptiveCard(version="1.4", body=[]) + attachment = card_attachment(AdaptiveCardAttachment(content=card)) + return TaskModuleResponse( + task=TaskModuleContinueResponse(value=CardTaskModuleTaskInfo(title="Test", card=attachment)) + ) + + # The type system should accept this - this test verifies type compatibility + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + activity = TaskFetchInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"dialog_id": "test_dialog"}), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 1 + assert handlers[0] == handle_dialog + + def test_on_dialog_open_returns_wrapped_response(self, app_with_options: App) -> None: + """Test that handlers can also return InvokeResponse[TaskModuleResponse] (wrapped).""" + + @app_with_options.on_dialog_open("test_dialog") + async def handle_dialog(ctx: ActivityContext[TaskFetchInvokeActivity]): + # Return wrapped InvokeResponse[TaskModuleResponse] + card = AdaptiveCard(version="1.4", body=[]) + attachment = card_attachment(AdaptiveCardAttachment(content=card)) + return InvokeResponse( + body=TaskModuleResponse( + task=TaskModuleContinueResponse(value=CardTaskModuleTaskInfo(title="Test", card=attachment)) + ) + ) + + # The type system should accept this too - verifies backward compatibility + from_account = Account(id="user-123", name="Test User", role="user") + recipient = Account(id="bot-456", name="Test Bot", role="bot") + conversation = ConversationAccount(id="conv-789", conversation_type="personal") + + activity = TaskFetchInvokeActivity( + id="test-activity-id", + type="invoke", + name="task/fetch", + from_=from_account, + recipient=recipient, + conversation=conversation, + channel_id="msteams", + value=TaskModuleRequest(data={"dialog_id": "test_dialog"}), + ) + + handlers = app_with_options.router.select_handlers(activity) + assert len(handlers) == 1 + assert handlers[0] == handle_dialog diff --git a/packages/cards/src/microsoft_teams/cards/__init__.py b/packages/cards/src/microsoft_teams/cards/__init__.py index c357ff22..840a2de9 100644 --- a/packages/cards/src/microsoft_teams/cards/__init__.py +++ b/packages/cards/src/microsoft_teams/cards/__init__.py @@ -3,10 +3,12 @@ Licensed under the MIT License. """ -from . import actions +from . import actions, utilities from .actions import * # noqa: F403 from .core import * +from .utilities import * # noqa: F403 # Combine all exports from submodules __all__: list[str] = [] __all__.extend(actions.__all__) +__all__.extend(utilities.__all__) diff --git a/packages/cards/src/microsoft_teams/cards/utilities/__init__.py b/packages/cards/src/microsoft_teams/cards/utilities/__init__.py new file mode 100644 index 00000000..6059474f --- /dev/null +++ b/packages/cards/src/microsoft_teams/cards/utilities/__init__.py @@ -0,0 +1,9 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .open_dialog import OpenDialogData +from .submit_action_data import EnhancedSubmitActionData as SubmitActionData + +__all__ = ["OpenDialogData", "SubmitActionData"] diff --git a/packages/cards/src/microsoft_teams/cards/utilities/open_dialog.py b/packages/cards/src/microsoft_teams/cards/utilities/open_dialog.py new file mode 100644 index 00000000..9cf4c001 --- /dev/null +++ b/packages/cards/src/microsoft_teams/cards/utilities/open_dialog.py @@ -0,0 +1,44 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Dict + +from ..core import SubmitActionData, TaskFetchSubmitActionData + +RESERVED_KEYWORD = "dialog_id" + + +class OpenDialogData(SubmitActionData): + """ + Represents the data required to open a dialog in Microsoft Teams using a submit action. + + This class extends `SubmitActionData` and is used to construct the payload for opening a dialog, + including a reserved dialog identifier and any additional data. + + Example: + >>> data = OpenDialogData("myDialogId", {"foo": "bar"}) + >>> # Use `data` as the payload for a Teams card submit action to open a dialog. + + Args: + dialog_identifier (str): The unique identifier for the dialog to open. + extra_data (Dict[str, Any] | None): Optional additional data to include in the payload. + """ + + def __init__(self, dialog_identifier: str, extra_data: Dict[str, Any] | None = None): + """ + Initialize an OpenDialogData instance. + + Args: + dialog_identifier (str): The unique identifier for the dialog to open. + extra_data (Dict[str, Any] | None): Optional additional data to include in the payload. + """ + super().__init__() + self.with_ms_teams(TaskFetchSubmitActionData().model_dump()) + if extra_data: + data = {**extra_data} + else: + data = {} + data[RESERVED_KEYWORD] = dialog_identifier + self.with_data(data) diff --git a/packages/cards/src/microsoft_teams/cards/utilities/submit_action_data.py b/packages/cards/src/microsoft_teams/cards/utilities/submit_action_data.py new file mode 100644 index 00000000..d75bc30e --- /dev/null +++ b/packages/cards/src/microsoft_teams/cards/utilities/submit_action_data.py @@ -0,0 +1,43 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Dict, Optional + +from ..core import SubmitActionData as BaseSubmitActionData + +RESERVED_KEYWORD = "action" + + +class EnhancedSubmitActionData(BaseSubmitActionData): + """ + Utility class for creating submit action data with action-based routing. + + This class extends the base SubmitActionData with a convenience constructor that + accepts an action identifier for routing submissions to specific handlers. + + Args: + action: The action identifier that determines which handler processes the submission. + data: Optional additional data to include with the submission. + + Example: + >>> submit_data = SubmitActionData(action="submit_user_form", data={"user_id": "123"}) + >>> submit_action = SubmitAction(title="Submit").with_data(submit_data) + """ + + def __init__( + self, + action: Optional[str] = None, + data: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ): + # If action is provided, use convenience constructor + if action is not None: + super().__init__(**kwargs) + merged_data = data.copy() if data else {} + merged_data[RESERVED_KEYWORD] = action + self.with_data(merged_data) + else: + # Otherwise, use standard Pydantic initialization for model_validate + super().__init__(**kwargs) diff --git a/packages/cards/tests/test_core_serialization.py b/packages/cards/tests/test_core_serialization.py index 99c85f81..1ce5c669 100644 --- a/packages/cards/tests/test_core_serialization.py +++ b/packages/cards/tests/test_core_serialization.py @@ -19,6 +19,7 @@ TextBlock, ToggleInput, ) +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_adaptive_card_serialization(): @@ -31,7 +32,7 @@ def test_adaptive_card_serialization(): ActionSet( actions=[ ExecuteAction(title="Submit") - .with_data(SubmitActionData(ms_teams={"action": "submit_basic"})) + .with_data(SubmitActionData("submit_basic")) .with_associated_inputs("auto") ] ), @@ -59,8 +60,8 @@ def test_action_set_serialization(): """Test ActionSet with multiple actions serializes correctly.""" action_set = ActionSet( actions=[ - ExecuteAction(title="Execute").with_data(SubmitActionData(ms_teams={"action": "execute"})), - SubmitAction(title="Submit").with_data(SubmitActionData(ms_teams={"action": "submit"})), + ExecuteAction(title="Execute").with_data(SubmitActionData("execute")), + SubmitAction(title="Submit").with_data(SubmitActionData("submit")), ] ) @@ -219,7 +220,7 @@ def test_submit_action_data_ms_teams_serialization(): # Test round-trip deserialization deserialized_action = SubmitAction.model_validate(parsed) - assert isinstance(deserialized_action.data, SubmitActionData) + assert isinstance(deserialized_action.data, BaseSubmitActionData) assert deserialized_action.data.ms_teams is not None assert deserialized_action.data.ms_teams["type"] == "task/fetch" diff --git a/packages/cards/tests/test_im_back_action.py b/packages/cards/tests/test_im_back_action.py index 6f16056b..716d2d88 100644 --- a/packages/cards/tests/test_im_back_action.py +++ b/packages/cards/tests/test_im_back_action.py @@ -3,11 +3,12 @@ Licensed under the MIT License. """ -from microsoft_teams.cards import IMBackAction, SubmitActionData +from microsoft_teams.cards import IMBackAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_im_back_action_initialization(): action = IMBackAction(value="Test Value") - assert isinstance(action.data, SubmitActionData) + assert isinstance(action.data, BaseSubmitActionData) assert action.data.ms_teams is not None assert action.data.ms_teams["value"] == "Test Value" diff --git a/packages/cards/tests/test_invoke_action.py b/packages/cards/tests/test_invoke_action.py index b09e678b..2fbdc3c3 100644 --- a/packages/cards/tests/test_invoke_action.py +++ b/packages/cards/tests/test_invoke_action.py @@ -3,11 +3,12 @@ Licensed under the MIT License. """ -from microsoft_teams.cards import InvokeAction, SubmitActionData +from microsoft_teams.cards import InvokeAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_invoke_action_initialization(): action = InvokeAction({"test": "Test Value"}) - assert isinstance(action.data, SubmitActionData) + assert isinstance(action.data, BaseSubmitActionData) assert action.data.ms_teams is not None assert action.data.ms_teams["value"]["test"] == "Test Value" diff --git a/packages/cards/tests/test_message_back_action.py b/packages/cards/tests/test_message_back_action.py index 321891c5..4116a7cf 100644 --- a/packages/cards/tests/test_message_back_action.py +++ b/packages/cards/tests/test_message_back_action.py @@ -3,12 +3,13 @@ Licensed under the MIT License. """ -from microsoft_teams.cards import MessageBackAction, SubmitActionData +from microsoft_teams.cards import MessageBackAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_message_back_action_initialization(): action = MessageBackAction(text="Message Back Test", value="Test Value", display_text="Test Text") - assert isinstance(action.data, SubmitActionData) + assert isinstance(action.data, BaseSubmitActionData) assert action.data.ms_teams is not None assert action.data.ms_teams["value"] == "Test Value" assert action.data.ms_teams["text"] == "Message Back Test" diff --git a/packages/cards/tests/test_sign_in_action.py b/packages/cards/tests/test_sign_in_action.py index 00d3d64f..5eb2a38d 100644 --- a/packages/cards/tests/test_sign_in_action.py +++ b/packages/cards/tests/test_sign_in_action.py @@ -3,11 +3,12 @@ Licensed under the MIT License. """ -from microsoft_teams.cards import SignInAction, SubmitActionData +from microsoft_teams.cards import SignInAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_sign_in_action_initialization(): action = SignInAction(value="Test Value") - assert isinstance(action.data, SubmitActionData) + assert isinstance(action.data, BaseSubmitActionData) assert action.data.ms_teams is not None assert action.data.ms_teams["value"] == "Test Value" diff --git a/packages/cards/tests/test_task_fetch_action.py b/packages/cards/tests/test_task_fetch_action.py index f5bada16..bedc7a00 100644 --- a/packages/cards/tests/test_task_fetch_action.py +++ b/packages/cards/tests/test_task_fetch_action.py @@ -3,12 +3,13 @@ Licensed under the MIT License. """ -from microsoft_teams.cards import SubmitActionData, TaskFetchAction +from microsoft_teams.cards import TaskFetchAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_invoke_action_initialization(): action = TaskFetchAction({"test": "Test Value"}) - assert isinstance(action.data, SubmitActionData) + assert isinstance(action.data, BaseSubmitActionData) assert action.data.ms_teams is not None # ms_teams should contain the task/fetch type assert action.data.ms_teams["type"] == "task/fetch"