diff --git a/examples/html-widgets/README.md b/examples/html-widgets/README.md new file mode 100644 index 000000000..da46be6b7 --- /dev/null +++ b/examples/html-widgets/README.md @@ -0,0 +1,73 @@ +# HTML Widgets Example + +This example demonstrates the full HTML widget capabilities for Teams bots using the Python SDK. + +## Commands + +| Command | Description | +|---------|-------------| +| `/simple` | Static widget (no callbacks) | +| `/calltool` | Widget with onCallTool (tools/call) | +| `/messageback` | Widget with onMessage (messageBack) | +| `/fullscreen` | Widget requesting fullscreen display mode | +| `/multi` | Widget with multiple tools (getTime, roll, echo) | +| `/openlink` | Widget with ui/open-link | +| `/context` | Widget with ui/update-model-context | +| `/hostcontext` | Inspect hostContext from ui/initialize | +| `/validate` | Security policy validation demo | +| `/help` | List available commands | + +## Architecture + +``` +src/ + main.py # Bot entry point, command routing, callTool handler + widgets/ + __init__.py # Re-exports all widget HTML constants + simple.py # Static widget HTML + calltool.py # CallTool interactive widget HTML + fullscreen.py # Fullscreen request widget HTML + messageback.py # MessageBack widget HTML + multi_tool.py # Multi-tool dispatch widget HTML + open_link.py # Open link widget HTML + update_context.py # Update model context widget HTML + host_context.py # Host context inspector widget HTML +``` + +## Running + +```bash +# From the repo root +cd examples/html-widgets +cp ../../.env .env # Or create .env with CLIENT_ID, CLIENT_SECRET, TENANT_ID + +# Install dependencies (uses workspace) +uv sync + +# Start the bot +uv run python src/main.py +``` + +## Widget Response Format + +The `on_widget_call_tool` handler returns an `HtmlWidgetCallToolResponse`: + +```python +@app.on_widget_call_tool +async def handle(ctx): + return HtmlWidgetCallToolResponse( + response_type="htmlwidget/calltoolresult", + call_tool_result=McpUiCallToolResult( + content=[{"type": "text", "text": "Result"}], + structured_content={"key": "value"}, + is_error=False, + ), + ) +``` + +## Key Concepts + +- **`build_html_widget_message`**: Builds a complete message activity with the widget, including protocol injection and security policy defaults. +- **`build_html_widget_markdown`**: Lower-level helper that returns the markdown string (for embedding in custom activities). +- **`validate_security_policy`**: Dev-time audit tool that checks HTML for external references not covered by the security policy. +- **`inject_widget_protocol`**: Auto-injected by the builders. Handles the ui/initialize handshake, size reporting, and optional notification hooks. diff --git a/examples/html-widgets/pyproject.toml b/examples/html-widgets/pyproject.toml new file mode 100644 index 000000000..d4bbeee96 --- /dev/null +++ b/examples/html-widgets/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "html-widgets" +version = "0.1.0" +description = "HTML Widgets example bot" +readme = "README.md" +requires-python = ">=3.11,<4.0" +dependencies = [ + "dotenv>=0.9.9", + "microsoft-teams-apps", + "microsoft-teams-api", +] + +[tool.uv.sources] +microsoft-teams-apps = { workspace = true } diff --git a/examples/html-widgets/src/main.py b/examples/html-widgets/src/main.py new file mode 100644 index 000000000..812e5cdeb --- /dev/null +++ b/examples/html-widgets/src/main.py @@ -0,0 +1,370 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +HTML Widgets Example Bot + +This example demonstrates the full HTML widget capabilities for Teams bots. +Each command shows a different widget feature that developers can use as +a reference for building their own widget-enabled bots. +""" + +import asyncio +import json +import logging +import random +from datetime import datetime, timezone +from typing import Any + +from microsoft_teams.api import HtmlWidgetCallToolInvokeActivity, MessageActivity +from microsoft_teams.api.activities.message import MessageActivityInput +from microsoft_teams.api.models.html_widget import ( + HtmlWidgetCallToolResponse, + HtmlWidgetPayload, + HtmlWidgetSecurityPolicy, + McpUiCallToolResult, + McpUiCallToolResultContent, +) +from microsoft_teams.apps import ActivityContext, App +from microsoft_teams.apps.utils.html_widget import ( + HtmlWidgetMarkdownOptions, + build_html_widget_markdown, + build_html_widget_message, + validate_security_policy, +) +from widgets import ( + CALLTOOL_WIDGET_HTML, + FULLSCREEN_WIDGET_HTML, + HOST_CONTEXT_WIDGET_HTML, + MESSAGEBACK_WIDGET_HTML, + MULTI_WIDGET_HTML, + OPEN_LINK_WIDGET_HTML, + SIMPLE_WIDGET_HTML, + UPDATE_CONTEXT_WIDGET_HTML, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = App() + + +# --------------------------------------------------------------------------- +# Message commands +# --------------------------------------------------------------------------- + + +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]) -> None: + """Route message commands to the appropriate widget demo.""" + if not ctx.activity.text: + return + text = ctx.activity.text.strip().lower() + + # Simple static widget - no callbacks + if text == "/simple": + message = build_html_widget_message( + HtmlWidgetPayload( + name="Simple Widget", + description="A static HTML widget with no callbacks.", + html=SIMPLE_WIDGET_HTML, + domain="https://teams.microsoft.com", + security_policy=HtmlWidgetSecurityPolicy( + connect_domains=[], + resource_domains=["'self'", "data:"], + frame_domains=[], + base_uri_domains=[], + ), + ), + HtmlWidgetMarkdownOptions( + before="Here is a simple static widget:", + after="No callbacks needed for static content.", + ), + ) + await ctx.send(message) + return + + # Widget with onCallTool callback + if text == "/calltool": + message = build_html_widget_message( + HtmlWidgetPayload( + name="CallTool Widget", + description="Widget that calls tools on the bot.", + html=CALLTOOL_WIDGET_HTML, + domain="https://teams.microsoft.com", + security_policy=HtmlWidgetSecurityPolicy( + connect_domains=[ + "https://teams.microsoft.com", + "https://teams.cloud.microsoft.com", + ], + resource_domains=["'self'", "data:"], + frame_domains=[], + base_uri_domains=[], + ), + tool_input={"demo": True}, + tool_output={ + "content": [{"type": "text", "text": "Initial data loaded."}], + "structuredContent": {"counter": 0, "lastAction": "init"}, + "isError": False, + }, + ), + HtmlWidgetMarkdownOptions(before="Here is a widget with callTool support (click Refresh):"), + ) + await ctx.send(message) + return + + # Widget with onMessage (messageBack) callback + if text == "/messageback": + message = build_html_widget_message( + HtmlWidgetPayload( + name="MessageBack Widget", + description="Widget that sends messageBack to the bot.", + html=MESSAGEBACK_WIDGET_HTML, + domain="https://teams.microsoft.com", + security_policy=HtmlWidgetSecurityPolicy( + connect_domains=[], + resource_domains=["'self'", "data:"], + frame_domains=[], + base_uri_domains=[], + ), + ), + HtmlWidgetMarkdownOptions(before="This widget tests the onMessage (messageBack) callback:"), + ) + await ctx.send(message) + return + + # Widget requesting fullscreen display mode + if text == "/fullscreen": + message = build_html_widget_message( + HtmlWidgetPayload( + name="Fullscreen Widget", + description="Widget that requests fullscreen mode.", + html=FULLSCREEN_WIDGET_HTML, + domain="https://teams.microsoft.com", + security_policy=HtmlWidgetSecurityPolicy( + connect_domains=[], + resource_domains=["'self'", "data:"], + frame_domains=[], + base_uri_domains=[], + ), + ), + HtmlWidgetMarkdownOptions(before="This widget will request fullscreen mode:"), + ) + await ctx.send(message) + return + + # Widget with multiple tools + if text == "/multi": + message = build_html_widget_message( + HtmlWidgetPayload( + name="Multi-Tool Widget", + description="Widget that calls multiple different tools.", + html=MULTI_WIDGET_HTML, + domain="https://teams.microsoft.com", + security_policy=HtmlWidgetSecurityPolicy( + connect_domains=["https://teams.microsoft.com"], + resource_domains=["'self'", "data:"], + frame_domains=[], + base_uri_domains=[], + ), + tool_input={}, + tool_output={ + "content": [{"type": "text", "text": "Ready."}], + "structuredContent": {"tools": ["getTime", "roll", "echo"]}, + "isError": False, + }, + ), + HtmlWidgetMarkdownOptions(before="This widget has multiple tools to test dispatch:"), + ) + await ctx.send(message) + return + + # Widget using ui/open-link + if text == "/openlink": + message = build_html_widget_message( + HtmlWidgetPayload( + name="open-link-test", + html=OPEN_LINK_WIDGET_HTML, + domain="https://teams.microsoft.com", + ), + HtmlWidgetMarkdownOptions(before="Widget with ui/open-link support (click a button to open a URL):"), + ) + await ctx.send(message) + return + + # Widget using ui/update-model-context + if text == "/context": + message = build_html_widget_message( + HtmlWidgetPayload( + name="update-context-test", + html=UPDATE_CONTEXT_WIDGET_HTML, + domain="https://teams.microsoft.com", + ), + HtmlWidgetMarkdownOptions(before="Widget with ui/update-model-context support:"), + ) + await ctx.send(message) + return + + # Host context inspector + if text == "/hostcontext": + message = build_html_widget_message( + HtmlWidgetPayload( + name="host-context-inspector", + html=HOST_CONTEXT_WIDGET_HTML, + domain="https://teams.microsoft.com", + ), + HtmlWidgetMarkdownOptions(before="Widget that inspects hostContext from ui/initialize:"), + ) + await ctx.send(message) + return + + # Security policy validation demo + if text == "/validate": + html_with_external_refs = ( + '' + '
' + "

Validation Demo

" + "

This widget was validated before sending.

" + "
" + ) + + # Step 1: validate against a restrictive policy to catch issues + strict_policy = HtmlWidgetSecurityPolicy( + connect_domains=[], + resource_domains=["'self'", "data:"], + frame_domains=[], + base_uri_domains=[], + ) + warnings = validate_security_policy(html_with_external_refs, strict_policy) + + # Step 2: fix the policy based on warnings, then build the widget + corrected_policy = HtmlWidgetSecurityPolicy( + connect_domains=[], + resource_domains=[ + "'self'", + "data:", + "https://fonts.googleapis.com", + ], + frame_domains=[], + base_uri_domains=[], + ) + warning_text = "\n".join(f"- **{w.source}**: `{w.url}` not in `{w.policy_field}`" for w in warnings) + markdown = build_html_widget_markdown( + HtmlWidgetPayload( + name="Validated Widget", + description="Widget built after security policy validation.", + html=html_with_external_refs, + domain="https://teams.microsoft.com", + security_policy=corrected_policy, + ), + HtmlWidgetMarkdownOptions( + before=( + f"**Validation found {len(warnings)} warning(s):**\n\n" + + warning_text + + "\n\nPolicy was corrected before sending:" + ), + ), + ) + await ctx.send(MessageActivityInput(text=markdown, text_format="extendedmarkdown")) + return + + # Help + if text in ("/help", "help"): + await ctx.send( + MessageActivityInput( + text_format="markdown", + text=( + "**HTML Widget Test Commands:**\n\n" + "- `/simple` - Static widget (no callbacks)\n" + "- `/calltool` - Widget with onCallTool\n" + "- `/messageback` - Widget with onMessage\n" + "- `/fullscreen` - Widget requesting fullscreen\n" + "- `/multi` - Widget with multiple tools\n" + "- `/openlink` - Widget with ui/open-link\n" + "- `/context` - Widget with ui/update-model-context\n" + "- `/hostcontext` - Inspect hostContext from initialize\n" + "- `/validate` - Security policy validation demo\n" + "- `/help` - This message" + ), + ) + ) + return + + # Handle messageBack values from the messageback widget + if ctx.activity.value: + await ctx.send(f"Received messageBack value: {json.dumps(ctx.activity.value)}") + return + + await ctx.send("Send `/help` for available widget test commands.") + + +# --------------------------------------------------------------------------- +# Handle htmlwidget/calltool invoke +# This is the typed handler for when a widget calls a tool on the bot. +# --------------------------------------------------------------------------- + + +@app.on_widget_call_tool +async def handle_widget_call_tool( + ctx: ActivityContext[HtmlWidgetCallToolInvokeActivity], +) -> HtmlWidgetCallToolResponse: + """Handle widget tool calls.""" + tool_name = ctx.activity.value.name + args: dict[str, Any] = ctx.activity.value.arguments or {} + logger.info(f"[widget.callTool] tool={tool_name!r} args={json.dumps(args)}") + + call_tool_result: McpUiCallToolResult + + if tool_name == "refresh": + counter = int(args.get("counter", 0) or 0) + 1 + call_tool_result = McpUiCallToolResult( + content=[McpUiCallToolResultContent(type="text", text="Refreshed!")], + structured_content={ + "counter": counter, + "lastAction": "refresh", + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + }, + is_error=False, + ) + + elif tool_name == "getTime": + now = datetime.now(tz=timezone.utc) + call_tool_result = McpUiCallToolResult( + content=[McpUiCallToolResultContent(type="text", text=now.strftime("%H:%M:%S"))], + structured_content={"time": now.isoformat()}, + is_error=False, + ) + + elif tool_name == "roll": + sides = int(args.get("sides", 6) or 6) + result = random.randint(1, sides) # noqa: S311 + call_tool_result = McpUiCallToolResult( + content=[McpUiCallToolResultContent(type="text", text=f"Rolled a {result} (d{sides})")], + structured_content={"result": result, "sides": sides}, + is_error=False, + ) + + elif tool_name == "echo": + call_tool_result = McpUiCallToolResult( + content=[McpUiCallToolResultContent(type="text", text=json.dumps(args))], + structured_content=args, + is_error=False, + ) + + else: + call_tool_result = McpUiCallToolResult( + content=[McpUiCallToolResultContent(type="text", text=f"Unknown tool: {tool_name}")], + is_error=True, + ) + + logger.info(f"[widget.callTool] result={call_tool_result}") + + return HtmlWidgetCallToolResponse( + response_type="htmlwidget/calltoolresult", + call_tool_result=call_tool_result, + ) + + +if __name__ == "__main__": + asyncio.run(app.start()) diff --git a/examples/html-widgets/src/widgets/__init__.py b/examples/html-widgets/src/widgets/__init__.py new file mode 100644 index 000000000..c0357b403 --- /dev/null +++ b/examples/html-widgets/src/widgets/__init__.py @@ -0,0 +1,26 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Widget HTML constants package. +""" + +from .calltool import CALLTOOL_WIDGET_HTML +from .fullscreen import FULLSCREEN_WIDGET_HTML +from .host_context import HOST_CONTEXT_WIDGET_HTML +from .messageback import MESSAGEBACK_WIDGET_HTML +from .multi_tool import MULTI_WIDGET_HTML +from .open_link import OPEN_LINK_WIDGET_HTML +from .simple import SIMPLE_WIDGET_HTML +from .update_context import UPDATE_CONTEXT_WIDGET_HTML + +__all__ = [ + "CALLTOOL_WIDGET_HTML", + "FULLSCREEN_WIDGET_HTML", + "HOST_CONTEXT_WIDGET_HTML", + "MESSAGEBACK_WIDGET_HTML", + "MULTI_WIDGET_HTML", + "OPEN_LINK_WIDGET_HTML", + "SIMPLE_WIDGET_HTML", + "UPDATE_CONTEXT_WIDGET_HTML", +] diff --git a/examples/html-widgets/src/widgets/calltool.py b/examples/html-widgets/src/widgets/calltool.py new file mode 100644 index 000000000..f8d7d02bc --- /dev/null +++ b/examples/html-widgets/src/widgets/calltool.py @@ -0,0 +1,44 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Widget HTML constants - calltool widget. +""" + +# CallTool widget - calls a "refresh" tool on the bot and displays the result. +# +# This HTML includes the interactive calltool behavior (tools/call) since +# that is widget-specific logic, not boilerplate. The example bot uses +# inject_widget_protocol() automatically via the builders. +CALLTOOL_WIDGET_HTML = ( + '" + "

CallTool Widget

" + '

Click Refresh to call the bot\'s "refresh" tool.

' + '' + '
Waiting for action...
' + "" +) diff --git a/examples/html-widgets/src/widgets/fullscreen.py b/examples/html-widgets/src/widgets/fullscreen.py new file mode 100644 index 000000000..9c853ac65 --- /dev/null +++ b/examples/html-widgets/src/widgets/fullscreen.py @@ -0,0 +1,45 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Widget HTML constants - fullscreen widget. +""" + +# Fullscreen widget - requests fullscreen display mode from the host. +# +# Uses requestDisplayMode to ask the Teams host for fullscreen mode. +# The example bot uses inject_widget_protocol() automatically via the builders. +FULLSCREEN_WIDGET_HTML = ( + '" + "

Fullscreen Widget

" + "

Click the button to request fullscreen mode from Teams.

" + '' + '
' + "

In fullscreen mode, this widget will expand to fill the available space.

" + '

Current mode: inline

' + "
" + "" +) diff --git a/examples/html-widgets/src/widgets/host_context.py b/examples/html-widgets/src/widgets/host_context.py new file mode 100644 index 000000000..6d99d7d15 --- /dev/null +++ b/examples/html-widgets/src/widgets/host_context.py @@ -0,0 +1,98 @@ +# ruff: noqa: E501 +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Widget HTML constants - host context inspector widget. +""" + +# Host Context widget - displays the hostContext received during ui/initialize. +# Shows theme, display mode, container dimensions, locale, etc. +# Also listens for ui/notifications/host-context-changed. +HOST_CONTEXT_WIDGET_HTML = """ + + +

Host Context Inspector

+

Displays the hostContext from ui/initialize response and listens for changes.

+
+

Initialize Result

+
Waiting for initialize...
+
+
+

Host Context

+
-
+
+
+

Host Capabilities

+
-
+
+
+ +""" diff --git a/examples/html-widgets/src/widgets/messageback.py b/examples/html-widgets/src/widgets/messageback.py new file mode 100644 index 000000000..6247fe716 --- /dev/null +++ b/examples/html-widgets/src/widgets/messageback.py @@ -0,0 +1,38 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Widget HTML constants - messageback widget. +""" + +# MessageBack widget - sends a messageBack action to the bot. +# +# Uses the ui/message method to send a message to the conversation, +# similar to messageBack in Adaptive Cards. The example bot uses +# inject_widget_protocol() automatically via the builders. +MESSAGEBACK_WIDGET_HTML = ( + '" + "

MessageBack Widget

" + "

Click the button to send a messageBack to the bot.

" + '' + '
' + "" +) diff --git a/examples/html-widgets/src/widgets/multi_tool.py b/examples/html-widgets/src/widgets/multi_tool.py new file mode 100644 index 000000000..b2a5d6f1e --- /dev/null +++ b/examples/html-widgets/src/widgets/multi_tool.py @@ -0,0 +1,53 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Widget HTML constants - multi-tool widget. +""" + +# Multi-tool widget - calls multiple different tools on the bot. +# +# Each button calls tools/call with a different tool name. Teams routes +# each as an `htmlwidget/calltool` invoke activity to the bot. The example +# bot uses inject_widget_protocol() automatically via the builders. +MULTI_WIDGET_HTML = ( + '" + "

Multi-Tool Widget

" + "

Each button calls a different tool on the bot.

" + '
' + '' + '' + '' + '' + "
" + '
Available tools: getTime, roll, echo, unknownTool
' + "" +) diff --git a/examples/html-widgets/src/widgets/open_link.py b/examples/html-widgets/src/widgets/open_link.py new file mode 100644 index 000000000..18f30dda5 --- /dev/null +++ b/examples/html-widgets/src/widgets/open_link.py @@ -0,0 +1,64 @@ +# ruff: noqa: E501 +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Widget HTML constants - open link widget. +""" + +# Open Link widget - tests ui/open-link method. +# Clicking a button asks the host to open a URL in the user's browser. +OPEN_LINK_WIDGET_HTML = """ + + +

Open Link Widget

+

Tests the ui/open-link method (host opens a URL).

+
+ + + +
+
Waiting...
+ +""" diff --git a/examples/html-widgets/src/widgets/simple.py b/examples/html-widgets/src/widgets/simple.py new file mode 100644 index 000000000..996215b88 --- /dev/null +++ b/examples/html-widgets/src/widgets/simple.py @@ -0,0 +1,27 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Widget HTML constants - simple static widget. +""" + +# Simple static widget - no callbacks, no interactivity. +# Verifies that the host renders the HTML correctly. +# +# This is raw HTML without the MCP Apps protocol. The example bot uses +# inject_widget_protocol() automatically via the builders. +SIMPLE_WIDGET_HTML = ( + '" + "

Simple HTML Widget

" + "

This is a static HTML widget rendered inside a Teams message. No callbacks are needed.

" + '
Status: Rendered successfully
' + "" +) diff --git a/examples/html-widgets/src/widgets/update_context.py b/examples/html-widgets/src/widgets/update_context.py new file mode 100644 index 000000000..e5327baef --- /dev/null +++ b/examples/html-widgets/src/widgets/update_context.py @@ -0,0 +1,97 @@ +# ruff: noqa: E501 +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Widget HTML constants - update model context widget. +""" + +# Update Model Context widget - tests ui/update-model-context method. +# Sends structured context to the host that can be used by AI in future turns. +UPDATE_CONTEXT_WIDGET_HTML = """ + + +

Update Model Context Widget

+

Tests ui/update-model-context - sends context for AI to use in future turns.

+ +
+ + + +
+
Waiting...
+ +""" diff --git a/packages/api/src/microsoft_teams/api/activities/invoke/__init__.py b/packages/api/src/microsoft_teams/api/activities/invoke/__init__.py index 439c9bccd..7f3df4a8f 100644 --- a/packages/api/src/microsoft_teams/api/activities/invoke/__init__.py +++ b/packages/api/src/microsoft_teams/api/activities/invoke/__init__.py @@ -7,13 +7,14 @@ from pydantic import Field -from . import config, message_extension, sign_in, tab, task +from . import config, html_widget, message_extension, sign_in, tab, task from .adaptive_card import AdaptiveCardInvokeActivity from .config import * # noqa: F403 from .config import ConfigInvokeActivity from .execute_action import ExecuteActionInvokeActivity from .file_consent import FileConsentInvokeActivity from .handoff_action import HandoffActionInvokeActivity +from .html_widget import HtmlWidgetCallToolInvokeActivity from .message import ( MessageFetchTaskActionValue, MessageFetchTaskData, @@ -45,6 +46,7 @@ SignInInvokeActivity, AdaptiveCardInvokeActivity, SuggestedActionSubmitInvokeActivity, + HtmlWidgetCallToolInvokeActivity, ], Field(discriminator="name"), ] @@ -66,9 +68,11 @@ "SignInInvokeActivity", "AdaptiveCardInvokeActivity", "SuggestedActionSubmitInvokeActivity", + "HtmlWidgetCallToolInvokeActivity", ] __all__.extend(config.__all__) +__all__.extend(html_widget.__all__) __all__.extend(message_extension.__all__) __all__.extend(sign_in.__all__) __all__.extend(tab.__all__) diff --git a/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/__init__.py b/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/__init__.py new file mode 100644 index 000000000..723500da3 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/__init__.py @@ -0,0 +1,8 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .call_tool import HtmlWidgetCallToolInvokeActivity + +__all__ = ["HtmlWidgetCallToolInvokeActivity"] diff --git a/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/call_tool.py b/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/call_tool.py new file mode 100644 index 000000000..a61783f5d --- /dev/null +++ b/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/call_tool.py @@ -0,0 +1,28 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Literal + +from microsoft_teams.common.experimental import experimental + +from ....models.html_widget.call_tool_request import CallToolRequest +from ...invoke_activity import InvokeActivity + + +@experimental("ExperimentalTeamsHtmlWidget") +class HtmlWidgetCallToolInvokeActivity(InvokeActivity): + """ + Represents an activity that is sent when a widget calls a tool on the bot. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + + type: Literal["invoke"] = "invoke" + + name: Literal["htmlwidget/calltool"] = "htmlwidget/calltool" + """The name of the operation associated with the invoke activity.""" + + value: CallToolRequest + """The tool call request from the widget.""" diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py index df7e229fd..6f330417d 100644 --- a/packages/api/src/microsoft_teams/api/models/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/__init__.py @@ -12,6 +12,7 @@ conversation, entity, file, + html_widget, meetings, message, messaging_extension, @@ -40,6 +41,7 @@ from .entity import * # noqa: F403 from .error import ErrorResponse, HttpError, InnerHttpError from .file import * # noqa: F403 +from .html_widget import * # noqa: F403 from .importance import Importance from .input_hint import InputHint from .invoke_response import ( @@ -111,6 +113,7 @@ __all__.extend(conversation.__all__) __all__.extend(entity.__all__) __all__.extend(file.__all__) +__all__.extend(html_widget.__all__) __all__.extend(meetings.__all__) __all__.extend(message.__all__) __all__.extend(messaging_extension.__all__) diff --git a/packages/api/src/microsoft_teams/api/models/html_widget/__init__.py b/packages/api/src/microsoft_teams/api/models/html_widget/__init__.py new file mode 100644 index 000000000..8f2928ad2 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/html_widget/__init__.py @@ -0,0 +1,18 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .call_tool_request import CallToolRequest +from .call_tool_result import HtmlWidgetCallToolResponse, McpUiCallToolResult, McpUiCallToolResultContent +from .html_widget_payload import HtmlWidgetPayload, HtmlWidgetPermissions, HtmlWidgetSecurityPolicy + +__all__ = [ + "CallToolRequest", + "HtmlWidgetCallToolResponse", + "HtmlWidgetPayload", + "HtmlWidgetPermissions", + "HtmlWidgetSecurityPolicy", + "McpUiCallToolResult", + "McpUiCallToolResultContent", +] diff --git a/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_request.py b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_request.py new file mode 100644 index 000000000..eb3b00b67 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_request.py @@ -0,0 +1,26 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Optional + +from microsoft_teams.common.experimental import experimental + +from ..custom_base_model import CustomBaseModel + + +@experimental("ExperimentalTeamsHtmlWidget") +class CallToolRequest(CustomBaseModel): + """ + A request from a widget to call a tool on the bot. + Sent as the value of an htmlwidget/calltool invoke activity. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + + name: str + """The name of the tool to call.""" + + arguments: Optional[Any] = None + """The arguments to pass to the tool.""" diff --git a/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_result.py b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_result.py new file mode 100644 index 000000000..39d719fe3 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_result.py @@ -0,0 +1,56 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Literal, Optional + +from microsoft_teams.common.experimental import experimental + +from ..custom_base_model import CustomBaseModel + + +class McpUiCallToolResultContent(CustomBaseModel): + """A content item in an MCP UI call tool result.""" + + type: str + """The type of content (e.g. "text").""" + + text: str + """The text content.""" + + +@experimental("ExperimentalTeamsHtmlWidget") +class McpUiCallToolResult(CustomBaseModel): + """ + The result of a widget's tools/call request, returned by the bot + in response to an htmlwidget/calltool invoke activity. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + + content: Optional[list[McpUiCallToolResultContent]] = None + """An array of content items to return to the widget.""" + + structured_content: Optional[Any] = None + """Structured data that the widget can render from.""" + + is_error: Optional[bool] = None + """Whether the tool call resulted in an error.""" + + +@experimental("ExperimentalTeamsHtmlWidget") +class HtmlWidgetCallToolResponse(CustomBaseModel): + """ + The wire-format response body for an htmlwidget/calltool invoke. + Teams expects this shape (with responseType discriminator) rather than + a bare McpUiCallToolResult. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + + response_type: Literal["htmlwidget/calltoolresult"] = "htmlwidget/calltoolresult" + """Discriminator that tells Teams how to interpret the response.""" + + call_tool_result: McpUiCallToolResult + """The tool call result payload.""" diff --git a/packages/api/src/microsoft_teams/api/models/html_widget/html_widget_payload.py b/packages/api/src/microsoft_teams/api/models/html_widget/html_widget_payload.py new file mode 100644 index 000000000..402f36b6c --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/html_widget/html_widget_payload.py @@ -0,0 +1,79 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Literal, Optional + +from microsoft_teams.common.experimental import experimental + +from ..custom_base_model import CustomBaseModel + + +@experimental("ExperimentalTeamsHtmlWidget") +class HtmlWidgetSecurityPolicy(CustomBaseModel): + """ + The security policy for an HTML widget, controlling allowed origins + for network requests, static resources, nested iframes, and base URIs. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + + connect_domains: Optional[list[str]] = None + """Allowed origins for network requests.""" + + resource_domains: Optional[list[str]] = None + """Allowed origins for static resources.""" + + frame_domains: Optional[list[str]] = None + """Allowed origins for nested iframes.""" + + base_uri_domains: Optional[list[str]] = None + """Allowed base URIs for the document.""" + + +@experimental("ExperimentalTeamsHtmlWidget") +class HtmlWidgetPermissions(CustomBaseModel): + + camera: Optional[Any] = None + """Request camera access.""" + + microphone: Optional[Any] = None + """Request microphone access.""" + + geolocation: Optional[Any] = None + """Request geolocation access.""" + + clipboard_write: Optional[Any] = None + """Request clipboard write access.""" + + +@experimental("ExperimentalTeamsHtmlWidget") +class HtmlWidgetPayload(CustomBaseModel): + + type: Literal["widget/mcp-ui"] = "widget/mcp-ui" + """The widget type identifier. Currently only "widget/mcp-ui" is supported.""" + + name: str + """The display name of the MCP app.""" + + description: Optional[str] = None + """A description of the MCP app.""" + + html: str + """The HTML content that makes up the widget.""" + + domain: str + """The domain associated with the widget. Must start with 'https://'.""" + + security_policy: Optional[HtmlWidgetSecurityPolicy] = None + """Optional security policy controlling allowed origins.""" + + tool_input: Optional[Any] = None + """Optional data that was passed as input to the tool that produced this widget.""" + + tool_output: Optional[Any] = None + """Optional data that the tool produced alongside this widget.""" + + permissions: Optional[HtmlWidgetPermissions] = None + """Optional permissions the widget requests from the host.""" diff --git a/packages/api/src/microsoft_teams/api/models/invoke_response.py b/packages/api/src/microsoft_teams/api/models/invoke_response.py index 1579b3a74..74c02d1e3 100644 --- a/packages/api/src/microsoft_teams/api/models/invoke_response.py +++ b/packages/api/src/microsoft_teams/api/models/invoke_response.py @@ -8,6 +8,7 @@ from .adaptive_card.adaptive_card_action_response import AdaptiveCardActionResponse from .config.config_response import ConfigResponse from .custom_base_model import CustomBaseModel +from .html_widget.call_tool_result import HtmlWidgetCallToolResponse from .messaging_extension.messaging_extension_action_response import MessagingExtensionActionResponse from .messaging_extension.messaging_extension_response import MessagingExtensionResponse from .tab.tab_response import TabResponse @@ -27,6 +28,7 @@ TabResponse, # tab/fetch, tab/submit AdaptiveCardActionResponse, # adaptiveCard/action TokenExchangeInvokeResponse, # signin/tokenExchange + HtmlWidgetCallToolResponse, # htmlwidget/calltool ] # Type variable for generic invoke response T = TypeVar("T", bound=InvokeResponseBody) diff --git a/packages/apps/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py index 54ad5b922..c5c01e3a6 100644 --- a/packages/apps/src/microsoft_teams/apps/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/__init__.py @@ -15,6 +15,15 @@ from .options import AppOptions from .plugins import * # noqa: F401, F403 from .routing import ActivityContext +from .utils.html_widget import ( + HtmlWidgetMarkdownOptions, + InjectWidgetProtocolOptions, + SecurityPolicyWarning, + build_html_widget_markdown, + build_html_widget_message, + inject_widget_protocol, + validate_security_policy, +) from .utils.thread import to_threaded_conversation_id logging.getLogger(__name__).addHandler(logging.NullHandler()) @@ -29,6 +38,13 @@ "HttpStream", "ActivityContext", "to_threaded_conversation_id", + "build_html_widget_markdown", + "build_html_widget_message", + "inject_widget_protocol", + "validate_security_policy", + "HtmlWidgetMarkdownOptions", + "InjectWidgetProtocolOptions", + "SecurityPolicyWarning", ] __all__.extend(auth.__all__) __all__.extend(events.__all__) 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 3be9917d5..c24c7579e 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 @@ -531,6 +531,15 @@ class ActivityConfig: output_type_name="AdaptiveCardInvokeResponse", is_invoke=True, ), + "widget.call_tool": ActivityConfig( + name="widget.call_tool", + method_name="on_widget_call_tool", + input_model="HtmlWidgetCallToolInvokeActivity", + selector=lambda activity: activity.type == "invoke" + and cast(InvokeActivity, activity).name == "htmlwidget/calltool", + output_type_name="HtmlWidgetCallToolResponse", + 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 2636969b2..4bdec3509 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py +++ b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py @@ -24,6 +24,7 @@ ExecuteActionInvokeActivity, FileConsentInvokeActivity, HandoffActionInvokeActivity, + HtmlWidgetCallToolInvokeActivity, InstalledActivity, InstallUpdateActivity, InvokeActivity, @@ -59,6 +60,7 @@ from microsoft_teams.api.models.invoke_response import ( AdaptiveCardInvokeResponse, ConfigInvokeResponse, + HtmlWidgetCallToolResponse, MessagingExtensionActionInvokeResponse, MessagingExtensionInvokeResponse, TabInvokeResponse, @@ -1502,6 +1504,42 @@ def decorator( return decorator(handler) return decorator + @overload + def on_widget_call_tool( + self, handler: InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse] + ) -> InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]: ... + + @overload + def on_widget_call_tool( + self, + ) -> Callable[ + [InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]], + InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse], + ]: ... + + def on_widget_call_tool( + self, + handler: Optional[InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]] = None, + ) -> InvokeHandlerUnion[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]: + """Register a widget.call_tool activity handler.""" + + def decorator( + func: InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse], + ) -> InvokeHandler[HtmlWidgetCallToolInvokeActivity, HtmlWidgetCallToolResponse]: + validate_handler_type( + func, + HtmlWidgetCallToolInvokeActivity, + "on_widget_call_tool", + "HtmlWidgetCallToolInvokeActivity", + ) + config = ACTIVITY_ROUTES["widget.call_tool"] + self.router.add_handler(config.selector, func) + return func + + if handler is not None: + return decorator(handler) + return decorator + @overload def on_card_action( self, handler: InvokeHandler[AdaptiveCardInvokeActivity, AdaptiveCardInvokeResponse] diff --git a/packages/apps/src/microsoft_teams/apps/utils/html_widget.py b/packages/apps/src/microsoft_teams/apps/utils/html_widget.py new file mode 100644 index 000000000..04184c2af --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/utils/html_widget.py @@ -0,0 +1,475 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +HTML widget utilities for building and validating widget messages. + +Diagnostic: ExperimentalTeamsHtmlWidget +""" + +import json +import re +from dataclasses import dataclass, replace +from typing import Optional +from urllib.parse import urlparse + +from microsoft_teams.api.activities.message import MessageActivityInput +from microsoft_teams.api.models.html_widget import HtmlWidgetPayload, HtmlWidgetSecurityPolicy +from microsoft_teams.common.experimental import experimental + +# The MCP Apps protocol version used for the widget init handshake. +MCP_PROTOCOL_VERSION = "2026-01-26" + +# Explicit mapping of notification names to their window callback names. +# Only notifications in this map will have hooks injected. +# +# Supported in the Teams app bridge: tool-result, tool-input +# Not supported in the Teams app bridge: tool-input-partial, tool-cancelled +# Not yet available in Teams: host-context-changed, resource-teardown +NOTIFICATION_CALLBACKS: dict[str, str] = { + "tool-result": "onToolResult", + "tool-input": "onToolInput", + "tool-input-partial": "onToolInputPartial", + "tool-cancelled": "onToolCancelled", + "host-context-changed": "onHostContextChanged", + "resource-teardown": "onResourceTeardown", +} + +# Default security policy applied when none is specified. +DEFAULT_SECURITY_POLICY = HtmlWidgetSecurityPolicy( + connect_domains=[], + resource_domains=["'self'", "data:"], + frame_domains=[], + base_uri_domains=[], +) + + +# --------------------------------------------------------------------------- +# Types +# --------------------------------------------------------------------------- + + +@dataclass +class InjectWidgetProtocolOptions: + """Options for injecting the MCP Apps protocol into widget HTML.""" + + name: Optional[str] = None + """The widget app name sent during ui/initialize. Defaults to 'widget'.""" + + version: Optional[str] = None + """The widget app version sent during ui/initialize. Defaults to '1.0.0'.""" + + available_display_modes: Optional[list[str]] = None + """Display modes this widget supports (e.g. ['inline', 'fullscreen']).""" + + notifications: Optional[list[str]] = None + """Host notifications to listen for (e.g. ['tool-result', 'tool-input']).""" + + debug_csp_violations: bool = False + """When true, injects a CSP violation listener for dev-time debugging.""" + + +@dataclass +class HtmlWidgetMarkdownOptions: + """Options for building an HTML widget markdown string.""" + + before: Optional[str] = None + """Text to include before the widget code block.""" + + after: Optional[str] = None + """Text to include after the widget code block.""" + + protocol_options: Optional[InjectWidgetProtocolOptions] = None + """Options forwarded to inject_widget_protocol.""" + + +@dataclass +class SecurityPolicyWarning: + """ + A warning produced by validate_security_policy when the widget HTML + references an external origin not present in the declared security policy. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + + url: str + """The URL or origin found in the HTML.""" + + source: str + """The HTML element or API where the reference was found.""" + + policy_field: str + """The security_policy field that should include this origin.""" + + message: str + """A human-readable description of the issue.""" + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + + +def _validate_html_widget_payload(payload: HtmlWidgetPayload) -> None: + """Validates an HTML widget payload, raising if required fields are missing.""" + if not payload.name or not payload.name.strip(): + raise ValueError('HTML widget payload requires a non-empty "name" field.') + + if not payload.html or not payload.html.strip(): + raise ValueError('HTML widget payload requires a non-empty "html" field.') + + if not payload.domain or not payload.domain.strip() or not payload.domain.startswith("https://"): + raise ValueError('HTML widget payload requires "domain" to be a valid URL starting with "https://".') + + +# --------------------------------------------------------------------------- +# Protocol injection +# --------------------------------------------------------------------------- + + +def _escape_for_inline_script(value: str) -> str: + """Escape a string for safe embedding in a single-quoted JS literal inside breakout, and newlines. + """ + return ( + value.replace("\\", "\\\\").replace("'", "\\'").replace(" str: + """ + Injects the MCP Apps protocol script into widget HTML. + + This sets up: + - The ui/initialize handshake (required for rendering) + - Size reporting via ui/notifications/size-changed + - Optional notification hooks (opt-in via notifications option) + + If the HTML already contains the protocol (detected by 'ui/initialize'), it is returned unchanged. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + if "ui/initialize" in html: + return html + + opts = options or InjectWidgetProtocolOptions() + name = _escape_for_inline_script(opts.name or "widget") + version = _escape_for_inline_script(opts.version or "1.0.0") + caps_json = "{}" + if opts.available_display_modes: + caps_json = f"{{availableDisplayModes:{json.dumps(opts.available_display_modes)}}}" + + # Build notification hook lines + notifications = opts.notifications or [] + hook_lines = "" + for n in notifications: + if n in NOTIFICATION_CALLBACKS: + method = f"ui/notifications/{n}" + cb = NOTIFICATION_CALLBACKS[n] + hook_lines += f"if(d.method==='{method}'&&window.{cb}){{window.{cb}(d.params);}}" + + # CSP violation listener (dev-only, opt-in) + csp_debug = "" + if opts.debug_csp_violations: + csp_debug = ( + "document.addEventListener('securitypolicyviolation',function(e){" + "console.warn('[widget CSP violation]',{" + "blockedURI:e.blockedURI," + "violatedDirective:e.violatedDirective," + "originalPolicy:e.originalPolicy" + "});});" + ) + + # Script template parts (long JS strings, cannot break mid-expression) + notify_size = ( # noqa: E501 + "function notifySize(){window.parent.postMessage(" + "{jsonrpc:'2.0',method:'ui/notifications/size-changed'," + "params:{height:document.body.scrollHeight}},'*');}" + ) + on_init = ( + "if(d.id===id&&d.result){window.parent.postMessage(" + "{jsonrpc:'2.0',method:'ui/notifications/initialized'},'*');" + "setTimeout(notifySize,100);}" + ) + post_init = ( + "window.parent.postMessage({jsonrpc:'2.0',id:id," + f"method:'ui/initialize',params:{{protocolVersion:'{MCP_PROTOCOL_VERSION}'," + f"appInfo:{{name:'{name}',version:'{version}'}}," + f"appCapabilities:{caps_json}}}}},'*');" + ) + + script = ( + "" + ) + + # Inject before if present, otherwise append + if "" in html: + return html.replace("", script + "") + + return html + script + + +# --------------------------------------------------------------------------- +# Building / sending +# --------------------------------------------------------------------------- + + +@experimental("ExperimentalTeamsHtmlWidget") +def build_html_widget_markdown( + payload: HtmlWidgetPayload, + options: Optional[HtmlWidgetMarkdownOptions] = None, +) -> str: + """ + Wraps an HTML widget payload in the ```html-widget markdown code fence + format required by Teams to render the widget in a message. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + _validate_html_widget_payload(payload) + + opts = options or HtmlWidgetMarkdownOptions() + + # Build protocol options from markdown options (copy to avoid mutating caller's instance) + base_proto = opts.protocol_options or InjectWidgetProtocolOptions() + proto_opts = replace(base_proto, name=payload.name) + + # Inject protocol and apply default security policy (copy default to avoid shared mutation) + injected_html = inject_widget_protocol(payload.html, proto_opts) + security_policy = payload.security_policy or DEFAULT_SECURITY_POLICY.model_copy(deep=True) + + # Build the serialized payload + injected_payload = payload.model_copy(update={"html": injected_html, "security_policy": security_policy}) + payload_json = injected_payload.model_dump(by_alias=True, exclude_none=True) + json_str = json.dumps(payload_json, separators=(",", ":")) + + parts: list[str] = [] + if opts.before: + parts.extend([opts.before, ""]) + + parts.extend(["```html-widget", json_str, "```"]) + + if opts.after: + parts.extend(["", opts.after]) + + return "\n".join(parts) + + +@experimental("ExperimentalTeamsHtmlWidget") +def build_html_widget_message( + payload: HtmlWidgetPayload, + options: Optional[HtmlWidgetMarkdownOptions] = None, +) -> MessageActivityInput: + """ + Builds a message activity containing an HTML widget, ready to be sent. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + return MessageActivityInput( + text=build_html_widget_markdown(payload, options), + text_format="extendedmarkdown", + ) + + +# --------------------------------------------------------------------------- +# Security validation +# --------------------------------------------------------------------------- + + +def _extract_origin(url: str) -> Optional[str]: + """ + Extracts the origin (scheme + host) from a URL string. + Returns None if the URL is relative, a data URI, or unparseable. + """ + trimmed = url.strip() + if not trimmed or trimmed.startswith("data:") or trimmed.startswith("#") or trimmed.startswith("blob:"): + return None + + # Relative URLs are fine (they resolve to the iframe origin) + if "://" not in trimmed and not trimmed.startswith("//"): + return None + + try: + if trimmed.startswith("//"): + trimmed = f"https:{trimmed}" + parsed = urlparse(trimmed) + if parsed.scheme and parsed.netloc: + return f"{parsed.scheme}://{parsed.netloc}" + return None + except Exception: + return None + + +def _is_origin_allowed(origin: str, allowed_domains: list[str]) -> bool: + """Checks whether an origin is covered by a list of allowed domains/origins.""" + if "*" in allowed_domains: + return True + for domain in allowed_domains: + cleaned = domain.strip("'\"") + if cleaned == "*": + return True + if origin == cleaned: + return True + # Check subdomain match + domain_host = re.sub(r"^https?://", "", cleaned) + if origin.endswith(f".{domain_host}"): + return True + return False + + +def _policy_message(source: str, url: str, origin: str, field: str) -> str: + """Build a human-readable warning message for a policy violation.""" + return f'{source} references "{url}" but origin "{origin}" is not in {field}.' + + +def _find_tags(html: str, tag_name: str) -> list[str]: + """ + Find all opening tags by name using string scanning (O(n), no regex backtracking). + Returns the substring of each tag (from ''). + """ + tags: list[str] = [] + needle = f"<{tag_name}" + lower = html.lower() + pos = 0 + while pos < len(lower): + start = lower.find(needle, pos) + if start == -1: + break + after_tag = start + len(needle) + if after_tag < len(lower) and lower[after_tag] not in (" ", "\t", "\n", "\r", ">", "/"): + pos = after_tag + continue + end = html.find(">", start) + if end == -1: + break + tags.append(html[start : end + 1]) + pos = end + 1 + return tags + + +@experimental("ExperimentalTeamsHtmlWidget") +def validate_security_policy(html: str, policy: HtmlWidgetSecurityPolicy) -> list[SecurityPolicyWarning]: + """ + Validates that external references in widget HTML are covered by the + declared security policy. Returns a list of warnings for any references + to origins not present in the appropriate policy field. + + This is a static analysis tool - it cannot catch dynamically constructed URLs. + Use the debug_csp_violations option on inject_widget_protocol for runtime detection. + + Diagnostic: ExperimentalTeamsHtmlWidget + """ + warnings: list[SecurityPolicyWarning] = [] + + # resourceDomains: " + payload = MINIMAL_PAYLOAD.model_copy(update={"html": html_with_init}) + result = build_html_widget_markdown(payload) + parsed = _parse_widget_json(result) + assert parsed["html"] == html_with_init + + def test_uses_payload_name_as_protocol_app_name(self): + result = build_html_widget_markdown(MINIMAL_PAYLOAD) + parsed = _parse_widget_json(result) + assert "name:'Test Widget'" in parsed["html"] + + def test_includes_text_before_widget(self): + opts = HtmlWidgetMarkdownOptions(before="Check this out:") + result = build_html_widget_markdown(MINIMAL_PAYLOAD, opts) + assert result.startswith("Check this out:\n\n```html-widget\n") + + def test_includes_text_after_widget(self): + opts = HtmlWidgetMarkdownOptions(after="Pretty cool, right?") + result = build_html_widget_markdown(MINIMAL_PAYLOAD, opts) + assert result.endswith("\n```\n\nPretty cool, right?") + + def test_includes_text_before_and_after(self): + opts = HtmlWidgetMarkdownOptions(before="Before", after="After") + result = build_html_widget_markdown(MINIMAL_PAYLOAD, opts) + assert result.startswith("Before\n\n```html-widget\n") + assert result.endswith("\n```\n\nAfter") + + def test_forwards_protocol_options(self): + opts = HtmlWidgetMarkdownOptions(protocol_options=InjectWidgetProtocolOptions(notifications=["tool-result"])) + result = build_html_widget_markdown(MINIMAL_PAYLOAD, opts) + parsed = _parse_widget_json(result) + assert "ui/notifications/tool-result" in parsed["html"] + assert "window.onToolResult" in parsed["html"] + + def test_forwards_debug_csp_violations(self): + opts = HtmlWidgetMarkdownOptions(protocol_options=InjectWidgetProtocolOptions(debug_csp_violations=True)) + result = build_html_widget_markdown(MINIMAL_PAYLOAD, opts) + parsed = _parse_widget_json(result) + assert "securitypolicyviolation" in parsed["html"] + + def test_uses_payload_name_even_with_protocol_options(self): + opts = HtmlWidgetMarkdownOptions(protocol_options=InjectWidgetProtocolOptions(version="2.0.0")) + result = build_html_widget_markdown(MINIMAL_PAYLOAD, opts) + parsed = _parse_widget_json(result) + assert "name:'Test Widget'" in parsed["html"] + assert "version:'2.0.0'" in parsed["html"] + + def test_serializes_full_payload_with_all_fields(self): + result = build_html_widget_markdown(FULL_PAYLOAD) + parsed = _parse_widget_json(result) + assert parsed["type"] == "widget/mcp-ui" + assert parsed["name"] == "Weather Widget" + assert parsed["description"] == "Current weather conditions" + assert '
72F
' in parsed["html"] + assert "ui/initialize" in parsed["html"] + assert parsed["domain"] == "https://weather.example.com" + assert parsed["securityPolicy"]["connectDomains"] == ["https://api.example.com"] + assert parsed["toolInput"] == {"location": "Seattle, WA"} + assert parsed["permissions"] == {"clipboardWrite": {}} + + def test_does_not_overwrite_user_security_policy(self): + custom_policy = HtmlWidgetSecurityPolicy( + connect_domains=["https://api.custom.com"], + resource_domains=["https://cdn.custom.com"], + frame_domains=["https://embed.custom.com"], + base_uri_domains=[], + ) + payload = MINIMAL_PAYLOAD.model_copy(update={"security_policy": custom_policy}) + result = build_html_widget_markdown(payload) + parsed = _parse_widget_json(result) + assert parsed["securityPolicy"]["connectDomains"] == ["https://api.custom.com"] + assert parsed["securityPolicy"]["resourceDomains"] == ["https://cdn.custom.com"] + assert parsed["securityPolicy"]["frameDomains"] == ["https://embed.custom.com"] + + def test_handles_html_with_backticks(self): + payload = MINIMAL_PAYLOAD.model_copy(update={"html": "```some code```"}) + result = build_html_widget_markdown(payload) + assert result.startswith("```html-widget\n") + assert result.endswith("\n```") + parsed = _parse_widget_json(result) + assert "```some code```" in parsed["html"] + + def test_handles_html_with_newlines_and_special_chars(self): + payload = MINIMAL_PAYLOAD.model_copy(update={"html": "
\n

\"Hello\" & 'world'

\n
"}) + result = build_html_widget_markdown(payload) + # JSON is on a single line (compact), just parse the first line after fence + json_line = result.split("\n")[1] + parsed = json.loads(json_line) + assert "
\n

\"Hello\" & 'world'

\n
" in parsed["html"] + + def test_empty_options_no_extra_lines(self): + opts = HtmlWidgetMarkdownOptions(before="", after="") + result = build_html_widget_markdown(MINIMAL_PAYLOAD, opts) + assert result.startswith("```html-widget\n") + assert result.endswith("\n```") + assert not result.startswith("\n") + + def test_handles_payload_with_undefined_optional_fields(self): + payload = HtmlWidgetPayload( + name="Bare", + html="

minimal

", + domain="https://example.com", + ) + result = build_html_widget_markdown(payload) + json_line = result.split("\n")[1] + parsed = json.loads(json_line) + assert parsed["type"] == "widget/mcp-ui" + assert "description" not in parsed + assert parsed["securityPolicy"] == { + "connectDomains": [], + "resourceDomains": ["'self'", "data:"], + "frameDomains": [], + "baseUriDomains": [], + } + assert "toolInput" not in parsed + assert "permissions" not in parsed + + +# --------------------------------------------------------------------------- +# build_html_widget_message +# --------------------------------------------------------------------------- + + +class TestBuildHtmlWidgetMessage: + def test_returns_message_with_extendedmarkdown_format(self): + result = build_html_widget_message(MINIMAL_PAYLOAD) + assert result.type == "message" + assert result.text_format == "extendedmarkdown" + + def test_contains_widget_markdown_in_text(self): + result = build_html_widget_message(MINIMAL_PAYLOAD) + assert result.text == build_html_widget_markdown(MINIMAL_PAYLOAD) + + def test_passes_options_through(self): + opts = HtmlWidgetMarkdownOptions(before="Weather today:") + result = build_html_widget_message(FULL_PAYLOAD, opts) + assert result.text == build_html_widget_markdown(FULL_PAYLOAD, opts) + + def test_produces_activity_like_structure(self): + result = build_html_widget_message(MINIMAL_PAYLOAD) + assert result.type == "message" + assert result.text is not None + assert result.text_format is not None + + +# --------------------------------------------------------------------------- +# inject_widget_protocol +# --------------------------------------------------------------------------- + + +class TestInjectWidgetProtocol: + BARE_HTML = "

Hello

" + BARE_HTML_NO_BODY = "

Hello

" + + def test_injects_protocol_before_body_close(self): + result = inject_widget_protocol(self.BARE_HTML) + assert "ui/initialize" in result + assert "ui/notifications/size-changed" in result + assert "ui/notifications/initialized" in result + assert "" in result + script_idx = result.index("ui/initialize") + body_idx = result.index("") + assert script_idx < body_idx + + def test_appends_script_if_no_body_tag(self): + result = inject_widget_protocol(self.BARE_HTML_NO_BODY) + assert "ui/initialize" in result + assert "

Hello

" in result + + def test_uses_custom_name_and_version(self): + opts = InjectWidgetProtocolOptions(name="my-widget", version="2.0.0") + result = inject_widget_protocol(self.BARE_HTML, opts) + assert "name:'my-widget'" in result + assert "version:'2.0.0'" in result + + def test_uses_default_name_and_version(self): + result = inject_widget_protocol(self.BARE_HTML) + assert "name:'widget'" in result + assert "version:'1.0.0'" in result + + def test_does_not_modify_html_with_existing_protocol(self): + html_with_init = "" + result = inject_widget_protocol(html_with_init) + assert result == html_with_init + + def test_is_idempotent(self): + first = inject_widget_protocol(self.BARE_HTML) + second = inject_widget_protocol(first) + assert second == first + + def test_handles_empty_html(self): + result = inject_widget_protocol("") + assert "ui/initialize" in result + assert " in name from breaking out of the inline script.""" + opts = InjectWidgetProtocolOptions(name="") + result = inject_widget_protocol("", opts) + # Only one should exist (the injected protocol's closing tag) + import re + + script_tags = re.findall(r"", result, re.IGNORECASE) + assert len(script_tags) == 1 + assert "<\\/script>" in result + + def test_escapes_script_close_tag_in_version(self): + """Prevents in version from breaking out.""" + opts = InjectWidgetProtocolOptions(version='') + result = inject_widget_protocol("", opts) + import re + + script_tags = re.findall(r"", result, re.IGNORECASE) + assert len(script_tags) == 1 + + def test_escapes_newlines_in_name(self): + """Prevents newlines from breaking JS string literals.""" + opts = InjectWidgetProtocolOptions(name="line1\nline2\rline3") + result = inject_widget_protocol("", opts) + # Extract script content + import re + + script_match = re.search(r"", result, re.DOTALL | re.IGNORECASE) + assert script_match is not None + script_content = script_match.group(1) + # No raw newlines inside the script + assert "\n" not in script_content + assert "\\n" in script_content + assert "\\r" in script_content + + +class TestOptionsImmutability: + def test_does_not_mutate_protocol_options(self): + """buildHtmlWidgetMarkdown should not mutate the caller's protocolOptions.""" + proto_opts = InjectWidgetProtocolOptions(version="2.0.0", notifications=["tool-result"]) + options = HtmlWidgetMarkdownOptions(protocol_options=proto_opts) + payload = HtmlWidgetPayload( + name="MutationTest", + html="hi", + domain="https://example.com", + ) + build_html_widget_markdown(payload, options) + # The original proto_opts.name should remain unchanged + assert proto_opts.name is None + + +class TestProtocolVersion: + def test_embeds_correct_mcp_protocol_version(self): + result = inject_widget_protocol("") + assert "protocolVersion:'2026-01-26'" in result + + +class TestSubdomainMatching: + def test_allows_subdomain_when_parent_in_policy(self): + html = '' + warnings = validate_security_policy(html, HtmlWidgetSecurityPolicy(resource_domains=["https://example.com"])) + assert len(warnings) == 0 + + def test_does_not_allow_unrelated_domain_sharing_suffix(self): + html = '' + warnings = validate_security_policy(html, HtmlWidgetSecurityPolicy(resource_domains=["https://example.com"])) + assert len(warnings) == 1 + + +class TestUnicodeInWidgetName: + def test_passes_unicode_through_correctly(self): + opts = InjectWidgetProtocolOptions(name="Widget \u2764\ufe0f") + result = inject_widget_protocol("", opts) + assert "name:'Widget \u2764\ufe0f'" in result diff --git a/tests/integration/test_html_widgets.py b/tests/integration/test_html_widgets.py new file mode 100644 index 000000000..e2a55e2cb --- /dev/null +++ b/tests/integration/test_html_widgets.py @@ -0,0 +1,137 @@ +"""Integration tests for HTML widget messages (send, update, delete).""" + +import warnings + +import pytest +from microsoft_teams.api.activities import MessageActivityInput +from microsoft_teams.api.models.html_widget import ( + HtmlWidgetPayload, + HtmlWidgetPermissions, + HtmlWidgetSecurityPolicy, +) +from microsoft_teams.apps.utils.html_widget import HtmlWidgetMarkdownOptions, build_html_widget_markdown + +from conftest import TestFixture + +# Suppress experimental warnings in integration tests +warnings.filterwarnings("ignore", category=UserWarning, message=".*preview.*") + + +class TestHtmlWidgets: + """Tests that verify Teams accepts widget payloads via the Bot API.""" + + @pytest.mark.asyncio + async def test_send_widget_message(self, fixture: TestFixture): + """Send a widget message with extendedmarkdown textFormat.""" + if not fixture.is_canary: + pytest.skip("Widgets require canary service") + + markdown = build_html_widget_markdown( + HtmlWidgetPayload( + name="Integration Test Widget", + description="Verifies Teams accepts widget payload.", + html="

Integration test widget

", + domain="https://teams.microsoft.com", + security_policy=HtmlWidgetSecurityPolicy( + connect_domains=[], + resource_domains=["'self'", "data:"], + frame_domains=[], + base_uri_domains=[], + ), + permissions=HtmlWidgetPermissions(), + ), + HtmlWidgetMarkdownOptions(before="[PY Integration] HTML widget send test"), + ) + + result = await fixture.api.conversations.activities(fixture.config.conversation_id).create( + MessageActivityInput().with_text(markdown).with_text_format("extendedmarkdown") + ) + assert result.id is not None + + @pytest.mark.asyncio + async def test_send_widget_with_tool_data(self, fixture: TestFixture): + """Send a widget message with toolInput and toolOutput fields.""" + if not fixture.is_canary: + pytest.skip("Widgets require canary service") + + markdown = build_html_widget_markdown( + HtmlWidgetPayload( + name="ToolOutput Widget", + description="Widget with initial tool data.", + html="

Widget with tool data

", + domain="https://teams.microsoft.com", + security_policy=HtmlWidgetSecurityPolicy( + connect_domains=[], + resource_domains=["'self'"], + frame_domains=[], + base_uri_domains=[], + ), + tool_input={"query": "test"}, + tool_output={ + "content": [{"type": "text", "text": "Result data"}], + "structured_content": {"key": "value"}, + "is_error": False, + }, + permissions=HtmlWidgetPermissions(clipboard_write={}), + ), + ) + + result = await fixture.api.conversations.activities(fixture.config.conversation_id).create( + MessageActivityInput().with_text(markdown).with_text_format("extendedmarkdown") + ) + assert result.id is not None + + @pytest.mark.asyncio + async def test_update_widget_message(self, fixture: TestFixture): + """Send then update a widget message.""" + if not fixture.is_canary: + pytest.skip("Widgets require canary service") + + markdown = build_html_widget_markdown( + HtmlWidgetPayload( + name="Update Test Widget", + html="

Original content

", + domain="https://teams.microsoft.com", + ), + HtmlWidgetMarkdownOptions(before="[PY Integration] Widget update test - original"), + ) + + sent = await fixture.api.conversations.activities(fixture.config.conversation_id).create( + MessageActivityInput().with_text(markdown).with_text_format("extendedmarkdown") + ) + assert sent.id is not None + + updated_markdown = build_html_widget_markdown( + HtmlWidgetPayload( + name="Update Test Widget", + html="

Updated content

", + domain="https://teams.microsoft.com", + ), + HtmlWidgetMarkdownOptions(before="[PY Integration] Widget update test - updated"), + ) + + await fixture.api.conversations.activities(fixture.config.conversation_id).update( + sent.id, MessageActivityInput().with_text(updated_markdown).with_text_format("extendedmarkdown") + ) + + @pytest.mark.asyncio + async def test_delete_widget_message(self, fixture: TestFixture): + """Send then delete a widget message.""" + if not fixture.is_canary: + pytest.skip("Widgets require canary service") + + markdown = build_html_widget_markdown( + HtmlWidgetPayload( + name="Delete Test Widget", + html="

Will be deleted

", + domain="https://teams.microsoft.com", + ), + ) + + sent = await fixture.api.conversations.activities(fixture.config.conversation_id).create( + MessageActivityInput().with_text(markdown).with_text_format("extendedmarkdown") + ) + assert sent.id is not None + + await fixture.api.conversations.activities(fixture.config.conversation_id).delete(sent.id) + diff --git a/uv.lock b/uv.lock index 3ef2ba7ac..fda367e77 100644 --- a/uv.lock +++ b/uv.lock @@ -14,7 +14,9 @@ members = [ "cards", "dialogs", "echo", + "formatted-messaging", "graph", + "html-widgets", "http-adapters", "mcp-server", "meetings", @@ -967,6 +969,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] +[[package]] +name = "formatted-messaging" +version = "0.1.0" +source = { virtual = "examples/formatted-messaging" } +dependencies = [ + { name = "dotenv" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -1192,6 +1211,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, ] +[[package]] +name = "html-widgets" +version = "0.1.0" +source = { virtual = "examples/html-widgets" } +dependencies = [ + { name = "dotenv" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, +] + [[package]] name = "http-adapters" version = "0.1.0" @@ -1816,7 +1852,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "cryptography", specifier = ">=48.0.1" }, + { name = "cryptography", specifier = ">=3.4.0" }, { name = "dependency-injector", specifier = ">=4.48.1" }, { name = "fastapi", specifier = ">=0.115.13" }, { name = "microsoft-teams-api", editable = "packages/api" },