From 4c43f1be63f0ce701fa77ee687df977bb8561769 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 24 Nov 2025 08:37:27 -0800 Subject: [PATCH 1/8] Add Dialogs handler --- examples/cards/src/main.py | 126 ++--- examples/dialogs/src/main.py | 283 +++++------ .../teams/apps/routing/activity_handlers.py | 426 +++++++++++++++- .../apps/routing/activity_route_configs.py | 59 ++- .../teams/apps/routing/generated_handlers.py | 101 ---- .../apps/tests/test_card_action_routing.py | 289 +++++++++++ packages/apps/tests/test_dialog_routing.py | 471 ++++++++++++++++++ .../src/microsoft/teams/cards/__init__.py | 4 +- .../teams/cards/utilities/__init__.py | 9 + .../teams/cards/utilities/open_dialog.py | 22 + .../teams/cards/utilities/submit_dialog.py | 36 ++ 11 files changed, 1464 insertions(+), 362 deletions(-) create mode 100644 packages/apps/tests/test_card_action_routing.py create mode 100644 packages/apps/tests/test_dialog_routing.py create mode 100644 packages/cards/src/microsoft/teams/cards/utilities/__init__.py create mode 100644 packages/cards/src/microsoft/teams/cards/utilities/open_dialog.py create mode 100644 packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py diff --git a/examples/cards/src/main.py b/examples/cards/src/main.py index eb4d116c..2c320438 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 ( @@ -242,69 +240,71 @@ 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("submit_basic") +async def handle_submit_basic(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> AdaptiveCardInvokeResponse: + """Handle basic card submission.""" 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("Received submit_basic action data:", data) + notify_value = data.get("notify", "false") + await ctx.send(f"Basic card submitted! Notify setting: {notify_value}") + return AdaptiveCardActionMessageResponse( + status_code=200, + type="application/vnd.microsoft.activity.message", + value="Action processed successfully", + ) + + +@app.on_card_action("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("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("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 e88545fa..835564b7 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,7 @@ ) 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 +41,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 +57,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/src/microsoft/teams/apps/routing/activity_handlers.py b/packages/apps/src/microsoft/teams/apps/routing/activity_handlers.py index 4c866139..e92fd9e6 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,421 @@ 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( + self, + ) -> Callable[ + [InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]], + InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ]: + """ + Register a global card action handler for all card action events. + + Usage: + + @app.on_card_action + async def handle_all_actions( + ctx: ActivityContext[AdaptiveCardInvokeActivity], + ) -> AdaptiveCardInvokeResponse: + return InvokeResponse(...) + + """ + ... + + @overload + def on_card_action( + self, + action_or_handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: + """ + Register a global card action handler for all card action events. + + Usage: + + async def handle_all_actions( + ctx: ActivityContext[AdaptiveCardInvokeActivity], + ) -> AdaptiveCardInvokeResponse: + return InvokeResponse(...) + app.on_card_action(handle_all_actions) + + """ + ... + + @overload + def on_card_action( + self, action_or_handler: str + ) -> Callable[ + [InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]], + InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ]: + """ + Register a card action 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_card_action("submit_basic") + async def handle_basic_submit( + ctx: ActivityContext[AdaptiveCardInvokeActivity] + ) -> AdaptiveCardInvokeResponse: + return InvokeResponse(...) + + """ + ... + + @overload + def on_card_action( + self, + action_or_handler: str, + handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: + """ + Register a card action 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_basic_submit( + ctx: ActivityContext[AdaptiveCardInvokeActivity] + ) -> AdaptiveCardInvokeResponse: + return InvokeResponse(...) + app.on_card_action("submit_basic", handle_basic_submit) + + """ + ... + + def on_card_action( + self, + action_or_handler: Union[ + str, InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], None + ] = None, + handler: Optional[InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]] = None, + ) -> InvokeHandlerUnion[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: + """ + Register a card action handler. + + Args: + action_or_handler: Optional action identifier to match against the 'action' field in activity data, + or a handler function to match all card action 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", "AdaptiveCardInvokeActivity" + ) + + def selector(ctx: ActivityBase) -> bool: + if not isinstance(ctx, AdaptiveCardInvokeActivity): + return False + # If no action specified, match all card action events + if action_or_handler is None: + return True + # Otherwise, match specific action + if not ctx.value or not ctx.value.action: + return False + 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 3b3d86ef..4a96fd22 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", @@ -504,15 +503,15 @@ class ActivityConfig: output_model=None, is_invoke=True, ), - "card.action": ActivityConfig( - name="card.action", - method_name="on_card_action", - input_model="AdaptiveCardInvokeActivity", - selector=lambda activity: activity.type == "invoke" - and cast(InvokeActivity, activity).name == "adaptiveCard/action", - output_type_name="AdaptiveCardInvokeResponse", - is_invoke=True, - ), + # "card.action": ActivityConfig( + # name="card.action", + # method_name="on_card_action", + # input_model="AdaptiveCardInvokeActivity", + # selector=lambda activity: activity.type == "invoke" + # and cast(InvokeActivity, activity).name == "adaptiveCard/action", + # output_type_name="AdaptiveCardInvokeResponse", + # is_invoke=True, + # ), # Generic invoke handler (fallback for any invoke not matching specific aliases) "invoke": ActivityConfig( name="invoke", 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 23261474..4ce2dcdf 100644 --- a/packages/apps/src/microsoft/teams/apps/routing/generated_handlers.py +++ b/packages/apps/src/microsoft/teams/apps/routing/generated_handlers.py @@ -15,7 +15,6 @@ from microsoft.teams.api.activities import ( Activity, - AdaptiveCardInvokeActivity, CommandResultActivity, CommandSendActivity, ConfigFetchInvokeActivity, @@ -53,19 +52,15 @@ SignInVerifyStateInvokeActivity, TabFetchInvokeActivity, TabSubmitInvokeActivity, - TaskFetchInvokeActivity, - TaskSubmitInvokeActivity, TraceActivity, TypingActivity, UninstalledActivity, ) from microsoft.teams.api.models.invoke_response import ( - AdaptiveCardInvokeResponse, ConfigInvokeResponse, MessagingExtensionActionInvokeResponse, MessagingExtensionInvokeResponse, TabInvokeResponse, - TaskModuleInvokeResponse, TokenExchangeInvokeResponseType, ) @@ -1284,70 +1279,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] @@ -1581,38 +1512,6 @@ def decorator( return decorator(handler) return decorator - @overload - def on_card_action( - self, handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse] - ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: ... - - @overload - def on_card_action( - self, - ) -> Callable[ - [InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]], - InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], - ]: ... - - def on_card_action( - self, handler: Optional[InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]] = None - ) -> InvokeHandlerUnion[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: - """Register a card.action activity handler.""" - - def decorator( - func: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], - ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: - validate_handler_type( - self.logger, func, AdaptiveCardInvokeActivity, "on_card_action", "AdaptiveCardInvokeActivity" - ) - config = ACTIVITY_ROUTES["card.action"] - self.router.add_handler(config.selector, func) - return func - - if handler is not None: - return decorator(handler) - return decorator - @overload def on_invoke(self, handler: BasicHandler[InvokeActivity]) -> BasicHandler[InvokeActivity]: ... 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..4c47f559 --- /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 TestCardActionRouting: + """Test cases for card action 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_with_action_id(self, app_with_options: App) -> None: + """Test on_card_action with specific action matching.""" + + @app_with_options.on_card_action("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_global_handler(self, app_with_options: App) -> None: + """Test on_card_action without action matches all card actions.""" + + @app_with_options.on_card_action() + 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_multiple_specific_handlers(self, app_with_options: App) -> None: + """Test multiple specific action handlers coexist correctly.""" + + @app_with_options.on_card_action("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("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_decorator_syntax(self, app_with_options: App) -> None: + """Test on_card_action works with decorator syntax.""" + + @app_with_options.on_card_action("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_non_decorator_syntax(self, app_with_options: App) -> None: + """Test on_card_action 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("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_missing_action_field(self, app_with_options: App) -> None: + """Test on_card_action handler doesn't match when action field is missing.""" + + @app_with_options.on_card_action("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..e093c11c --- /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..b34dce35 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 * # 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..97f21113 --- /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_dialog 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..63f9bbd1 --- /dev/null +++ b/packages/cards/src/microsoft/teams/cards/utilities/open_dialog.py @@ -0,0 +1,22 @@ +""" +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): + def __init__(self, dialog_identifier: str, extra_data: Dict[str, Any] | None = None): + super() + 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_dialog.py b/packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py new file mode 100644 index 00000000..2f4d13bc --- /dev/null +++ b/packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py @@ -0,0 +1,36 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Dict + +from ..core import SubmitActionData + +RESERVED_KEYWORD = "action" + + +class EnhancedSubmitActionData(SubmitActionData): + """ + 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: str, data: Dict[str, Any] | None = None): + super().__init__() + if data: + merged_data = {**data} + else: + merged_data = {} + merged_data[RESERVED_KEYWORD] = action + self.with_data(merged_data) From 5a48dee9b46fdce3490ef1bf9506415e9432390a Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 24 Nov 2025 10:07:55 -0800 Subject: [PATCH 2/8] Fix --- examples/cards/src/main.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/examples/cards/src/main.py b/examples/cards/src/main.py index 2c320438..057b1aa7 100644 --- a/examples/cards/src/main.py +++ b/examples/cards/src/main.py @@ -18,6 +18,7 @@ ExecuteAction, NumberInput, OpenUrlAction, + SubmitActionData, TextBlock, ToggleInput, ) @@ -35,7 +36,9 @@ def create_basic_adaptive_card() -> AdaptiveCard: 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") ] ), ], @@ -108,7 +111,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"), ] From e85535f3613ddd05d7637aa2af6ff1bb06e34372 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 24 Nov 2025 12:20:00 -0800 Subject: [PATCH 3/8] Fix --- examples/cards/src/main.py | 4 +-- .../teams/cards/utilities/submit_dialog.py | 27 ++++++++++++------- .../cards/tests/test_core_serialization.py | 9 ++++--- packages/cards/tests/test_im_back_action.py | 5 ++-- packages/cards/tests/test_invoke_action.py | 5 ++-- .../cards/tests/test_message_back_action.py | 5 ++-- packages/cards/tests/test_sign_in_action.py | 5 ++-- .../cards/tests/test_task_fetch_action.py | 5 ++-- 8 files changed, 39 insertions(+), 26 deletions(-) diff --git a/examples/cards/src/main.py b/examples/cards/src/main.py index 057b1aa7..6b67c4b3 100644 --- a/examples/cards/src/main.py +++ b/examples/cards/src/main.py @@ -157,7 +157,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") ] ), @@ -208,7 +208,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") ] diff --git a/packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py b/packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py index 2f4d13bc..d75bc30e 100644 --- a/packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py +++ b/packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py @@ -3,14 +3,14 @@ Licensed under the MIT License. """ -from typing import Any, Dict +from typing import Any, Dict, Optional -from ..core import SubmitActionData +from ..core import SubmitActionData as BaseSubmitActionData RESERVED_KEYWORD = "action" -class EnhancedSubmitActionData(SubmitActionData): +class EnhancedSubmitActionData(BaseSubmitActionData): """ Utility class for creating submit action data with action-based routing. @@ -26,11 +26,18 @@ class EnhancedSubmitActionData(SubmitActionData): >>> submit_action = SubmitAction(title="Submit").with_data(submit_data) """ - def __init__(self, action: str, data: Dict[str, Any] | None = None): - super().__init__() - if data: - merged_data = {**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: - merged_data = {} - merged_data[RESERVED_KEYWORD] = action - self.with_data(merged_data) + # 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 76d2d9fb..b462439b 100644 --- a/packages/cards/tests/test_core_serialization.py +++ b/packages/cards/tests/test_core_serialization.py @@ -17,6 +17,7 @@ TextBlock, ToggleInput, ) +from microsoft.teams.cards.core import SubmitActionData as BaseSubmitActionData def test_adaptive_card_serialization(): @@ -29,7 +30,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") ] ), @@ -57,8 +58,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")), ] ) @@ -217,6 +218,6 @@ 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 666a65f8..fd7c224d 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 7ab52419..fffad788 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 ceba77ff..c5fec03a 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 3fb1ee36..06420e3d 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 f785c8fe..c85a9657 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" From 4ca5e714dca5aa08ba0afa9691699e09e0a23162 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 24 Nov 2025 12:40:03 -0800 Subject: [PATCH 4/8] Fix naming --- .../teams/cards/utilities/__init__.py | 2 +- .../teams/cards/utilities/open_dialog.py | 2 +- .../teams/cards/utilities/submit_dialog.py | 43 ------------------- 3 files changed, 2 insertions(+), 45 deletions(-) delete mode 100644 packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py diff --git a/packages/cards/src/microsoft/teams/cards/utilities/__init__.py b/packages/cards/src/microsoft/teams/cards/utilities/__init__.py index 97f21113..6059474f 100644 --- a/packages/cards/src/microsoft/teams/cards/utilities/__init__.py +++ b/packages/cards/src/microsoft/teams/cards/utilities/__init__.py @@ -4,6 +4,6 @@ """ from .open_dialog import OpenDialogData -from .submit_dialog import EnhancedSubmitActionData as SubmitActionData +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 index 63f9bbd1..233937ad 100644 --- a/packages/cards/src/microsoft/teams/cards/utilities/open_dialog.py +++ b/packages/cards/src/microsoft/teams/cards/utilities/open_dialog.py @@ -12,7 +12,7 @@ class OpenDialogData(SubmitActionData): def __init__(self, dialog_identifier: str, extra_data: Dict[str, Any] | None = None): - super() + super().__init__() self.with_ms_teams(TaskFetchSubmitActionData().model_dump()) if extra_data: data = {**extra_data} diff --git a/packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py b/packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py deleted file mode 100644 index d75bc30e..00000000 --- a/packages/cards/src/microsoft/teams/cards/utilities/submit_dialog.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -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) From ae1e40994b40ef5d04fa8aafe432430cd24147a7 Mon Sep 17 00:00:00 2001 From: Aamir Jawaid <48929123+heyitsaamir@users.noreply.github.com> Date: Mon, 24 Nov 2025 15:50:47 -0800 Subject: [PATCH 5/8] Update packages/cards/src/microsoft/teams/cards/utilities/open_dialog.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../teams/cards/utilities/open_dialog.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/cards/src/microsoft/teams/cards/utilities/open_dialog.py b/packages/cards/src/microsoft/teams/cards/utilities/open_dialog.py index 233937ad..4d68d90f 100644 --- a/packages/cards/src/microsoft/teams/cards/utilities/open_dialog.py +++ b/packages/cards/src/microsoft/teams/cards/utilities/open_dialog.py @@ -11,7 +11,28 @@ 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: From df7a5004a9fbaf067510248f366435bafbcfdb47 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Mon, 24 Nov 2025 16:02:38 -0800 Subject: [PATCH 6/8] Fix build --- .../cards/src/microsoft/teams/cards/core.py | 6 +++ .../cards/utilities/submit_action_data.py | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 packages/cards/src/microsoft/teams/cards/utilities/submit_action_data.py diff --git a/packages/cards/src/microsoft/teams/cards/core.py b/packages/cards/src/microsoft/teams/cards/core.py index 7e9ba810..74040527 100644 --- a/packages/cards/src/microsoft/teams/cards/core.py +++ b/packages/cards/src/microsoft/teams/cards/core.py @@ -31,6 +31,9 @@ def validation_alias_generator(field: str) -> str: if field == "ms_teams": return "msteams" + if field == "choices_data": + return "choices.data" + # All other fields are converted to camelCase return to_camel(field) @@ -50,6 +53,9 @@ def serialization_alias_generator(field: str) -> str: if field == "ms_teams": return "msteams" + if field == "choices_data": + return "choices.data" + # All other fields are converted to camelCase return to_camel(field) 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) From 1f21f235056a76e901f0b46160574c557c3e6edc Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Thu, 4 Dec 2025 18:28:07 -0800 Subject: [PATCH 7/8] Fix imports --- packages/apps/tests/test_card_action_routing.py | 6 +++--- packages/apps/tests/test_dialog_routing.py | 6 +++--- packages/cards/tests/test_core_serialization.py | 4 ++-- packages/cards/tests/test_im_back_action.py | 4 ++-- packages/cards/tests/test_invoke_action.py | 4 ++-- packages/cards/tests/test_message_back_action.py | 4 ++-- packages/cards/tests/test_sign_in_action.py | 4 ++-- packages/cards/tests/test_task_fetch_action.py | 4 ++-- 8 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/apps/tests/test_card_action_routing.py b/packages/apps/tests/test_card_action_routing.py index 4c47f559..8b140d28 100644 --- a/packages/apps/tests/test_card_action_routing.py +++ b/packages/apps/tests/test_card_action_routing.py @@ -7,18 +7,18 @@ from unittest.mock import MagicMock import pytest -from microsoft.teams.api import ( +from microsoft_teams.api import ( Account, AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse, ConversationAccount, ) -from microsoft.teams.api.models.adaptive_card import ( +from microsoft_teams.api.models.adaptive_card import ( AdaptiveCardActionMessageResponse, AdaptiveCardInvokeAction, AdaptiveCardInvokeValue, ) -from microsoft.teams.apps import ActivityContext, App +from microsoft_teams.apps import ActivityContext, App class TestCardActionRouting: diff --git a/packages/apps/tests/test_dialog_routing.py b/packages/apps/tests/test_dialog_routing.py index e093c11c..b59bcbdd 100644 --- a/packages/apps/tests/test_dialog_routing.py +++ b/packages/apps/tests/test_dialog_routing.py @@ -7,7 +7,7 @@ from unittest.mock import MagicMock import pytest -from microsoft.teams.api import ( +from microsoft_teams.api import ( Account, AdaptiveCardAttachment, CardTaskModuleTaskInfo, @@ -21,8 +21,8 @@ TaskSubmitInvokeActivity, card_attachment, ) -from microsoft.teams.apps import ActivityContext, App -from microsoft.teams.cards import AdaptiveCard +from microsoft_teams.apps import ActivityContext, App +from microsoft_teams.cards import AdaptiveCard class TestDialogRouting: diff --git a/packages/cards/tests/test_core_serialization.py b/packages/cards/tests/test_core_serialization.py index 80cca075..1ce5c669 100644 --- a/packages/cards/tests/test_core_serialization.py +++ b/packages/cards/tests/test_core_serialization.py @@ -6,7 +6,7 @@ import json from typing import Any, cast -from microsoft.teams.cards import ( +from microsoft_teams.cards import ( ActionSet, AdaptiveCard, ChoiceSetInput, @@ -19,7 +19,7 @@ TextBlock, ToggleInput, ) -from microsoft.teams.cards.core import SubmitActionData as BaseSubmitActionData +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_adaptive_card_serialization(): diff --git a/packages/cards/tests/test_im_back_action.py b/packages/cards/tests/test_im_back_action.py index fd7c224d..716d2d88 100644 --- a/packages/cards/tests/test_im_back_action.py +++ b/packages/cards/tests/test_im_back_action.py @@ -3,8 +3,8 @@ Licensed under the MIT License. """ -from microsoft.teams.cards import IMBackAction -from microsoft.teams.cards.core import SubmitActionData as BaseSubmitActionData +from microsoft_teams.cards import IMBackAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_im_back_action_initialization(): diff --git a/packages/cards/tests/test_invoke_action.py b/packages/cards/tests/test_invoke_action.py index fffad788..2fbdc3c3 100644 --- a/packages/cards/tests/test_invoke_action.py +++ b/packages/cards/tests/test_invoke_action.py @@ -3,8 +3,8 @@ Licensed under the MIT License. """ -from microsoft.teams.cards import InvokeAction -from microsoft.teams.cards.core import SubmitActionData as BaseSubmitActionData +from microsoft_teams.cards import InvokeAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_invoke_action_initialization(): diff --git a/packages/cards/tests/test_message_back_action.py b/packages/cards/tests/test_message_back_action.py index c5fec03a..4116a7cf 100644 --- a/packages/cards/tests/test_message_back_action.py +++ b/packages/cards/tests/test_message_back_action.py @@ -3,8 +3,8 @@ Licensed under the MIT License. """ -from microsoft.teams.cards import MessageBackAction -from microsoft.teams.cards.core import SubmitActionData as BaseSubmitActionData +from microsoft_teams.cards import MessageBackAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_message_back_action_initialization(): diff --git a/packages/cards/tests/test_sign_in_action.py b/packages/cards/tests/test_sign_in_action.py index 06420e3d..5eb2a38d 100644 --- a/packages/cards/tests/test_sign_in_action.py +++ b/packages/cards/tests/test_sign_in_action.py @@ -3,8 +3,8 @@ Licensed under the MIT License. """ -from microsoft.teams.cards import SignInAction -from microsoft.teams.cards.core import SubmitActionData as BaseSubmitActionData +from microsoft_teams.cards import SignInAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_sign_in_action_initialization(): diff --git a/packages/cards/tests/test_task_fetch_action.py b/packages/cards/tests/test_task_fetch_action.py index c85a9657..bedc7a00 100644 --- a/packages/cards/tests/test_task_fetch_action.py +++ b/packages/cards/tests/test_task_fetch_action.py @@ -3,8 +3,8 @@ Licensed under the MIT License. """ -from microsoft.teams.cards import TaskFetchAction -from microsoft.teams.cards.core import SubmitActionData as BaseSubmitActionData +from microsoft_teams.cards import TaskFetchAction +from microsoft_teams.cards.core import SubmitActionData as BaseSubmitActionData def test_invoke_action_initialization(): From 4f2db6401036191485b6f7ac364949c893a57390 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Wed, 10 Dec 2025 18:34:09 -0800 Subject: [PATCH 8/8] on_card_action -> on_card_execute --- examples/cards/src/main.py | 61 +++++++++++++--- .../apps/routing/activity_handlers.py | 72 ++++++++++++------- .../apps/routing/activity_route_configs.py | 18 ++--- .../apps/routing/generated_handlers.py | 34 +++++++++ .../apps/tests/test_card_action_routing.py | 40 +++++------ 5 files changed, 161 insertions(+), 64 deletions(-) diff --git a/examples/cards/src/main.py b/examples/cards/src/main.py index 197121bb..d16a457e 100644 --- a/examples/cards/src/main.py +++ b/examples/cards/src/main.py @@ -28,11 +28,11 @@ 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=[ @@ -46,6 +46,24 @@ def create_basic_adaptive_card() -> AdaptiveCard: 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") + ] + ), + ], + ) + return card + + def create_model_validate_card() -> AdaptiveCard: """Create an adaptive card using model_validate to test deserialization.""" card = AdaptiveCard.model_validate( @@ -168,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.""" @@ -243,13 +269,26 @@ async def handle_feedback_card(ctx: ActivityContext[MessageActivity]): await ctx.send(card) -@app.on_card_action("submit_basic") +@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 + 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.""" + """Handle basic card submission - specific action routing.""" data = ctx.activity.value.action.data - print("Received submit_basic action data:", data) notify_value = data.get("notify", "false") - await ctx.send(f"Basic card submitted! Notify setting: {notify_value}") + 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", @@ -257,7 +296,7 @@ async def handle_submit_basic(ctx: ActivityContext[AdaptiveCardInvokeActivity]) ) -@app.on_card_action("submit_feedback") +@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 @@ -271,7 +310,7 @@ async def handle_submit_feedback(ctx: ActivityContext[AdaptiveCardInvokeActivity ) -@app.on_card_action("create_task") +@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 @@ -287,7 +326,7 @@ async def handle_create_task(ctx: ActivityContext[AdaptiveCardInvokeActivity]) - ) -@app.on_card_action("save_profile") +@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 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 a2d3d243..85ad183a 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_handlers.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_handlers.py @@ -403,77 +403,83 @@ def selector(ctx: ActivityBase) -> bool: return decorator @overload - def on_card_action( + def on_card_action_execute( self, ) -> Callable[ [InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]], InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], ]: """ - Register a global card action handler for all card action events. + Register a global handler for all Action.Execute card actions. Usage: - @app.on_card_action - async def handle_all_actions( + @app.on_card_action_execute + async def handle_all_execute_actions( ctx: ActivityContext[AdaptiveCardInvokeActivity], ) -> AdaptiveCardInvokeResponse: - return InvokeResponse(...) + return AdaptiveCardActionMessageResponse(...) """ ... @overload - def on_card_action( + def on_card_action_execute( self, action_or_handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: """ - Register a global card action handler for all card action events. + Register a global handler for all Action.Execute card actions. Usage: - async def handle_all_actions( + async def handle_all_execute_actions( ctx: ActivityContext[AdaptiveCardInvokeActivity], ) -> AdaptiveCardInvokeResponse: - return InvokeResponse(...) - app.on_card_action(handle_all_actions) + return AdaptiveCardActionMessageResponse(...) + app.on_card_action_execute(handle_all_execute_actions) """ ... @overload - def on_card_action( + def on_card_action_execute( self, action_or_handler: str ) -> Callable[ [InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]], InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], ]: """ - Register a card action handler that matches a specific action. + 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("submit_basic") + @app.on_card_action_execute("submit_basic") async def handle_basic_submit( ctx: ActivityContext[AdaptiveCardInvokeActivity] ) -> AdaptiveCardInvokeResponse: - return InvokeResponse(...) + return AdaptiveCardActionMessageResponse(...) """ ... @overload - def on_card_action( + def on_card_action_execute( self, action_or_handler: str, handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: """ - Register a card action handler that matches a specific action. + 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 @@ -484,13 +490,13 @@ def on_card_action( async def handle_basic_submit( ctx: ActivityContext[AdaptiveCardInvokeActivity] ) -> AdaptiveCardInvokeResponse: - return InvokeResponse(...) - app.on_card_action("submit_basic", handle_basic_submit) + return AdaptiveCardActionMessageResponse(...) + app.on_card_action_execute("submit_basic", handle_basic_submit) """ ... - def on_card_action( + def on_card_action_execute( self, action_or_handler: Union[ str, InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], None @@ -498,11 +504,15 @@ def on_card_action( handler: Optional[InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]] = None, ) -> InvokeHandlerUnion[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: """ - Register a card action handler. + 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 card action events. + or a handler function to match all Action.Execute events. handler: The async function to call when the event matches Returns: @@ -518,23 +528,37 @@ def decorator( func: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: validate_handler_type( - self.logger, func, AdaptiveCardInvokeActivity, "on_card_action", "AdaptiveCardInvokeActivity" + 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 card action events + + # 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 + + # 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) 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 9296d46e..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 @@ -503,15 +503,15 @@ class ActivityConfig: output_model=None, is_invoke=True, ), - # "card.action": ActivityConfig( - # name="card.action", - # method_name="on_card_action", - # input_model="AdaptiveCardInvokeActivity", - # selector=lambda activity: activity.type == "invoke" - # and cast(InvokeActivity, activity).name == "adaptiveCard/action", - # output_type_name="AdaptiveCardInvokeResponse", - # is_invoke=True, - # ), + "card.action": ActivityConfig( + name="card.action", + method_name="on_card_action", + input_model="AdaptiveCardInvokeActivity", + selector=lambda activity: activity.type == "invoke" + and cast(InvokeActivity, activity).name == "adaptiveCard/action", + output_type_name="AdaptiveCardInvokeResponse", + is_invoke=True, + ), # Generic invoke handler (fallback for any invoke not matching specific aliases) "invoke": ActivityConfig( name="invoke", 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 0506ea27..b47cfe01 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py +++ b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py @@ -15,6 +15,7 @@ from microsoft_teams.api.activities import ( Activity, + AdaptiveCardInvokeActivity, CommandResultActivity, CommandSendActivity, ConfigFetchInvokeActivity, @@ -57,6 +58,7 @@ UninstalledActivity, ) from microsoft_teams.api.models.invoke_response import ( + AdaptiveCardInvokeResponse, ConfigInvokeResponse, MessagingExtensionActionInvokeResponse, MessagingExtensionInvokeResponse, @@ -1512,6 +1514,38 @@ def decorator( return decorator(handler) return decorator + @overload + def on_card_action( + self, handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse] + ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: ... + + @overload + def on_card_action( + self, + ) -> Callable[ + [InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]], + InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ]: ... + + def on_card_action( + self, handler: Optional[InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]] = None + ) -> InvokeHandlerUnion[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: + """Register a card.action activity handler.""" + + def decorator( + func: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse], + ) -> InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse]: + validate_handler_type( + self.logger, func, AdaptiveCardInvokeActivity, "on_card_action", "AdaptiveCardInvokeActivity" + ) + config = ACTIVITY_ROUTES["card.action"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + @overload def on_invoke(self, handler: BasicHandler[InvokeActivity]) -> BasicHandler[InvokeActivity]: ... diff --git a/packages/apps/tests/test_card_action_routing.py b/packages/apps/tests/test_card_action_routing.py index 8b140d28..ae139f6a 100644 --- a/packages/apps/tests/test_card_action_routing.py +++ b/packages/apps/tests/test_card_action_routing.py @@ -21,8 +21,8 @@ from microsoft_teams.apps import ActivityContext, App -class TestCardActionRouting: - """Test cases for card action routing functionality.""" +class TestCardActionExecuteRouting: + """Test cases for card action execute routing functionality.""" @pytest.fixture def mock_logger(self): @@ -44,10 +44,10 @@ def app_with_options(self, mock_logger, mock_storage): client_secret="test-secret", ) - def test_on_card_action_with_action_id(self, app_with_options: App) -> None: - """Test on_card_action with specific action matching.""" + 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("submit_form") + @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" @@ -94,10 +94,10 @@ async def handle_submit_form(ctx: ActivityContext[AdaptiveCardInvokeActivity]) - non_matching_handlers = app_with_options.router.select_handlers(non_matching_activity) assert len(non_matching_handlers) == 0 - def test_on_card_action_global_handler(self, app_with_options: App) -> None: - """Test on_card_action without action matches all card actions.""" + 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() + @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" @@ -143,16 +143,16 @@ async def handle_all_actions(ctx: ActivityContext[AdaptiveCardInvokeActivity]) - assert len(handlers2) == 1 assert handlers2[0] == handle_all_actions - def test_on_card_action_multiple_specific_handlers(self, app_with_options: App) -> None: + 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("submit_form") + @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("save_data") + @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" @@ -197,10 +197,10 @@ async def handle_save_data(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> assert len(save_handlers) == 1 assert save_handlers[0] == handle_save_data - def test_on_card_action_decorator_syntax(self, app_with_options: App) -> None: - """Test on_card_action works with decorator syntax.""" + 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("test_action") + @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" @@ -227,15 +227,15 @@ async def decorated_handler(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> assert len(handlers) == 1 assert handlers[0] == decorated_handler - def test_on_card_action_non_decorator_syntax(self, app_with_options: App) -> None: - """Test on_card_action works with non-decorator syntax.""" + 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("non_decorated_action", handler_function) + 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") @@ -258,10 +258,10 @@ async def handler_function(ctx: ActivityContext[AdaptiveCardInvokeActivity]) -> assert len(handlers) == 1 assert handlers[0] == handler_function - def test_on_card_action_missing_action_field(self, app_with_options: App) -> None: - """Test on_card_action handler doesn't match when action field is missing.""" + 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("submit_form") + @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"