From e04960141de1d5403702d166563204c444e7ff5c Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:27:31 -0700 Subject: [PATCH 01/14] Invoke --- .../api/activities/invoke/__init__.py | 6 ++- .../activities/invoke/html_widget/__init__.py | 8 ++++ .../invoke/html_widget/call_tool.py | 25 ++++++++++++ .../apps/routing/activity_route_configs.py | 10 +++++ .../apps/routing/generated_handlers.py | 40 +++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/microsoft_teams/api/activities/invoke/html_widget/__init__.py create mode 100644 packages/api/src/microsoft_teams/api/activities/invoke/html_widget/call_tool.py 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..79ede6c94 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/activities/invoke/html_widget/call_tool.py @@ -0,0 +1,25 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Literal + +from ....models.html_widget.call_tool_request import CallToolRequest +from ...invoke_activity import InvokeActivity + + +class HtmlWidgetCallToolInvokeActivity(InvokeActivity): + """ + Represents an activity that is sent when a widget calls a tool on the bot. + + @experimental This API is in preview and may change in the future. + """ + + 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/apps/src/microsoft_teams/apps/routing/activity_route_configs.py b/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py index 3be9917d5..10965b259 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 @@ -19,6 +19,7 @@ ExecuteActionInvokeActivity, FileConsentInvokeActivity, HandoffActionInvokeActivity, + HtmlWidgetCallToolInvokeActivity, InstalledActivity, InvokeActivity, MeetingEndEventActivity, @@ -531,6 +532,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..037a4d1b8 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,44 @@ 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] From 88ed1febe6288409572205fa615de30c7d7e3ce8 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:28:32 -0700 Subject: [PATCH 02/14] HTML Widgets --- .../microsoft_teams/api/models/__init__.py | 3 + .../api/models/html_widget/__init__.py | 18 + .../models/html_widget/call_tool_request.py | 21 + .../models/html_widget/call_tool_result.py | 52 ++ .../models/html_widget/html_widget_payload.py | 85 ++ .../api/models/invoke_response.py | 2 + .../apps/src/microsoft_teams/apps/__init__.py | 16 + .../microsoft_teams/apps/utils/html_widget.py | 468 +++++++++++ packages/apps/tests/test_html_widget.py | 724 ++++++++++++++++++ 9 files changed, 1389 insertions(+) create mode 100644 packages/api/src/microsoft_teams/api/models/html_widget/__init__.py create mode 100644 packages/api/src/microsoft_teams/api/models/html_widget/call_tool_request.py create mode 100644 packages/api/src/microsoft_teams/api/models/html_widget/call_tool_result.py create mode 100644 packages/api/src/microsoft_teams/api/models/html_widget/html_widget_payload.py create mode 100644 packages/apps/src/microsoft_teams/apps/utils/html_widget.py create mode 100644 packages/apps/tests/test_html_widget.py diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py index df7e229fd..8e6c99130 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, @@ -56,6 +57,7 @@ is_invoke_response, ) from .meetings import * # noqa: F403 +from .html_widget import * # noqa: F403 from .message import * # noqa: F403 from .messaging_extension import * # noqa: F403 from .o365 import * # noqa: F403 @@ -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..6767f2d22 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_request.py @@ -0,0 +1,21 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Optional + +from ...models.custom_base_model import CustomBaseModel + + +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. + """ + + 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..f1c0863e7 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/html_widget/call_tool_result.py @@ -0,0 +1,52 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Literal, Optional + +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.""" + + +class McpUiCallToolResult(CustomBaseModel): + """ + The result of a widget's tools/call request, returned by the bot + in response to an htmlwidget/calltool invoke activity. + + @experimental This API is in preview and may change in the future. + """ + + 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.""" + + +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. + + @experimental This API is in preview and may change in the future. + """ + + 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..1060ef820 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/html_widget/html_widget_payload.py @@ -0,0 +1,85 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Literal, Optional + +from ..custom_base_model import CustomBaseModel + + +class HtmlWidgetSecurityPolicy(CustomBaseModel): + """ + The security policy for an HTML widget, controlling allowed origins + for network requests, static resources, nested iframes, and base URIs. + + @experimental This API is in preview and may change in the future. + """ + + 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.""" + + +class HtmlWidgetPermissions(CustomBaseModel): + """ + Permissions that the widget may request from the host. + + @experimental This API is in preview and may change in the future. + """ + + 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.""" + + +class HtmlWidgetPayload(CustomBaseModel): + """ + The JSON payload for an HTML widget, sent inside a ```html-widget code block + within a Markdown message. + + @experimental This API is in preview and may change in the future. + """ + + 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/utils/html_widget.py b/packages/apps/src/microsoft_teams/apps/utils/html_widget.py new file mode 100644 index 000000000..e0c20ae55 --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/utils/html_widget.py @@ -0,0 +1,468 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +HTML widget utilities for building and validating widget messages. + +@experimental This API is in preview and may change in the future. +""" + +import json +import re +from dataclasses import dataclass +from typing import Any, Optional +from urllib.parse import urlparse + +from microsoft_teams.api.models.html_widget import HtmlWidgetPayload, HtmlWidgetSecurityPolicy + +# 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. + + @experimental This API is in preview and may change in the future. + """ + + 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 inject_widget_protocol(html: str, options: Optional[InjectWidgetProtocolOptions] = None) -> 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. + + @experimental This API is in preview and may change in the future. + """ + if "ui/initialize" in html: + return html + + opts = options or InjectWidgetProtocolOptions() + name = (opts.name or "widget").replace("\\", "\\\\").replace("'", "\\'") + version = (opts.version or "1.0.0").replace("\\", "\\\\").replace("'", "\\'") + + 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 +# --------------------------------------------------------------------------- + + +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. + + @experimental This API is in preview and may change in the future. + """ + _validate_html_widget_payload(payload) + + opts = options or HtmlWidgetMarkdownOptions() + + # Build protocol options from markdown options + proto_opts = opts.protocol_options or InjectWidgetProtocolOptions() + proto_opts.name = payload.name + + # Inject protocol and apply default security policy + injected_html = inject_widget_protocol(payload.html, proto_opts) + security_policy = payload.security_policy or DEFAULT_SECURITY_POLICY + + # 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) + + +def build_html_widget_message( + payload: HtmlWidgetPayload, + options: Optional[HtmlWidgetMarkdownOptions] = None, +) -> dict[str, Any]: + """ + Builds a message activity containing an HTML widget, ready to be sent. + + @experimental This API is in preview and may change in the future. + """ + return { + "type": "message", + "text": build_html_widget_markdown(payload, options), + "textFormat": "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 ' + f'"{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 + + +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. + + @experimental This API is in preview and may change in the future. + """ + 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): + from microsoft_teams.apps.utils.html_widget import HtmlWidgetMarkdownOptions + + 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): + from microsoft_teams.apps.utils.html_widget import HtmlWidgetMarkdownOptions + + 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): + from microsoft_teams.apps.utils.html_widget import HtmlWidgetMarkdownOptions + + 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): + from microsoft_teams.apps.utils.html_widget import HtmlWidgetMarkdownOptions + + 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): + from microsoft_teams.apps.utils.html_widget import HtmlWidgetMarkdownOptions + + 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): + from microsoft_teams.apps.utils.html_widget import HtmlWidgetMarkdownOptions + + 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): + from microsoft_teams.apps.utils.html_widget import HtmlWidgetMarkdownOptions + + 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["textFormat"] == "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): + from microsoft_teams.apps.utils.html_widget import HtmlWidgetMarkdownOptions + + 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 "type" in result + assert "text" in result + assert "textFormat" in result + + +# --------------------------------------------------------------------------- +# 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 " and newlines in widget name/version The inline script injection used only backslash/quote escaping, which allowed a sequence in the widget name or version to break out of the script context and inject arbitrary HTML/JS into the widget iframe. Fix: add _escape_for_inline_script() that also escapes breakout) and newlines (prevents JS string literal breakout). Added 3 tests covering the breakout cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../microsoft_teams/apps/utils/html_widget.py | 15 ++++++-- packages/apps/tests/test_html_widget.py | 37 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/utils/html_widget.py b/packages/apps/src/microsoft_teams/apps/utils/html_widget.py index 6baa05a3d..04184c2af 100644 --- a/packages/apps/src/microsoft_teams/apps/utils/html_widget.py +++ b/packages/apps/src/microsoft_teams/apps/utils/html_widget.py @@ -127,6 +127,16 @@ def _validate_html_widget_payload(payload: HtmlWidgetPayload) -> None: # --------------------------------------------------------------------------- +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: """ @@ -145,9 +155,8 @@ def inject_widget_protocol(html: str, options: Optional[InjectWidgetProtocolOpti return html opts = options or InjectWidgetProtocolOptions() - name = (opts.name or "widget").replace("\\", "\\\\").replace("'", "\\'") - version = (opts.version or "1.0.0").replace("\\", "\\\\").replace("'", "\\'") - + 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)}}}" diff --git a/packages/apps/tests/test_html_widget.py b/packages/apps/tests/test_html_widget.py index a3f075cae..2db6737f1 100644 --- a/packages/apps/tests/test_html_widget.py +++ b/packages/apps/tests/test_html_widget.py @@ -701,3 +701,40 @@ def test_does_not_inject_when_explicitly_false(self): opts = InjectWidgetProtocolOptions(debug_csp_violations=False) result = inject_widget_protocol("

Hello

", opts) assert "securitypolicyviolation" not in result + + +class TestScriptInjectionPrevention: + def test_escapes_script_close_tag_in_name(self): + """Prevents 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) + 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 From 35214597e206097c9b1aa45b4445d4c6e748784d Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:54:48 -0700 Subject: [PATCH 13/14] Integration tests --- packages/apps/tests/test_html_widget.py | 40 +++++++ tests/integration/test_html_widgets.py | 137 ++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 tests/integration/test_html_widgets.py diff --git a/packages/apps/tests/test_html_widget.py b/packages/apps/tests/test_html_widget.py index 2db6737f1..1d4ab4db9 100644 --- a/packages/apps/tests/test_html_widget.py +++ b/packages/apps/tests/test_html_widget.py @@ -738,3 +738,43 @@ def test_escapes_newlines_in_name(self): 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) + From c1940e6ce36b0d3f1839ae0d68956d4810c9184b Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:01:07 -0700 Subject: [PATCH 14/14] Regex fix --- packages/apps/tests/test_html_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/tests/test_html_widget.py b/packages/apps/tests/test_html_widget.py index 1d4ab4db9..3a04ff553 100644 --- a/packages/apps/tests/test_html_widget.py +++ b/packages/apps/tests/test_html_widget.py @@ -731,7 +731,7 @@ def test_escapes_newlines_in_name(self): # Extract script content import re - script_match = re.search(r"", result, re.DOTALL) + 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