Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@

- **Slack-Aware Turn Context**: Added `SlackTurnContext`, `SlackActivity`, and `SlackRouteHandler` so Slack handlers receive a typed context with direct access to Slack channel data and the per-turn API client.
- **Route Decorators**: Added typed `SlackAgentExtension.message()` and `.event()` decorators. Deprecated `on_message()` and `on_event()` while retaining them as backward-compatible aliases.
- **Slack-Scoped Application Route Hooks**: Added `SlackAgentExtension.conversation_update()`, `.members_added()`, `.members_removed()`, and `.installation_update()`, which delegate to the matching `AgentApplication` hooks filtered to the Slack channel.
- **Typed Model Lookups**: Added a `type_` parameter and typed overloads to `SlackModel.get()` for converting and validating nested values with Pydantic.

## Major Features & Enhancements

- **Outbound Host Validation Hardening**: Enhanced `OutboundHostValidator` with HTTPS-only URL enforcement, DNS resolution validation for public and optionally private network addresses, and an SSRF-aware `aiohttp` connector that validates resolved IPv4 and IPv6 addresses before connecting.
- **Channel-Aware Route Selectors**: Added reusable route selectors, optional `channel_id` filtering to `AgentApplication` route hooks, and Teams-channel scoping for direct `TeamsAgentExtension` routes.
- **New Application Route Hooks**: Added `AgentApplication.event()`, `.invoke()`, `.installation_update()`, `.members_added()`, `.members_removed()`, `.end_of_conversation()`, `.message_reaction_added()`, and `.message_reactions_removed()`; `message()`, `event()`, and `invoke()` accept a string, regex, or list selector, and match everything at `RouteRank.LAST` when the selector is omitted. Added Teams-scoped `TeamsAgentExtension.installation_update()` and `.end_of_conversation()`, plus the Slack-scoped hooks listed above.

## Deprecation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,51 @@ def get_channel(channel_id: str | ChannelId | None) -> str | None:
channel_id = channel_id.strip()
parsed = channel_id.split(":", 1)[0].strip() if ":" in channel_id else None
return parsed or channel_id

def includes(self, other: str | ChannelId | None) -> bool:
"""Check whether this channel ID, used as a filter, includes another channel ID.

The comparison is directional: ``self`` is the filter and ``other`` is the
candidate. Matching rules:

- The channel must be equal, unless this channel is the wildcard ``*``.
- A filter without a sub-channel (e.g. ``msteams``) includes the bare channel
and every sub-channel of it (e.g. ``msteams:copilot``).
- A filter with the wildcard sub-channel (e.g. ``msteams:*``) behaves the same
as a filter without a sub-channel.
- A filter with a specific sub-channel (e.g. ``msteams:copilot``) includes only
that exact sub-channel.

Comparisons are case-sensitive. ``None`` and invalid candidates are never
included.

Example::

ChannelId("msteams").includes("msteams:copilot") # True
ChannelId("msteams:copilot").includes("msteams") # False
ChannelId("*").includes("slack") # True

:param other: The candidate channel ID to check.
:return: True if the candidate is included by this channel ID, False otherwise.
"""

if other is None:
return False

candidate: ChannelId
try:
candidate = ChannelId(other)
except Exception:
return False

if self.channel != "*" and self.channel != candidate.channel:
return False

if (
self.sub_channel
and self.sub_channel != "*"
and self.sub_channel != candidate.sub_channel
):
return False

return True
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@

import re


from microsoft_agents.activity import ActivityTypes, ChannelId
from microsoft_agents.hosting.core import TurnContext
from ._type_defs import RouteSelector
from ._type_defs import RouteSelector, TextSelector
from ._utils import _matches_text


def _text_selector(
Expand All @@ -22,38 +22,42 @@ def _text_selector(

def __selector(context: TurnContext) -> bool:
"""Selector function that checks if the given text matches the selection criteria."""
text = context.activity.text or ""
if isinstance(select, list):
for item in select:
if isinstance(item, re.Pattern):
if re.fullmatch(item, text) is not None:
return True
elif text == item:
return True
return False
return _matches_text(select, context.activity.text or "")

return __selector


if isinstance(select, re.Pattern):
return re.fullmatch(select, text) is not None
def _event_selector(event_selector: TextSelector | None) -> RouteSelector:
"""Creates a route selector that matches the given event name.

return text == select
:param event_selector: The text selector for the event name to match against.
:return: A route selector function that matches the specified event name.
"""

def __selector(context: TurnContext) -> bool:
"""Selector function that checks if the given event name matches the selection criteria."""
if not context.activity.type == ActivityTypes.event:
return False
return event_selector is None or _matches_text(
event_selector, context.activity.name or ""
)

return __selector


def _invoke_event_selector(event_name: str) -> RouteSelector:
def _invoke_event_selector(event_selector: TextSelector | None) -> RouteSelector:
"""Creates a route selector that matches the given invoke event name.

:param event_name: The name of the invoke event to match against.
:param event_selector: The text selector for the invoke event name to match against.
:return: A route selector function that matches the specified invoke event name.
"""

def __selector(context: TurnContext) -> bool:
"""Selector function that checks if the given invoke event name matches the selection criteria."""
if context.activity.name is None:
if not context.activity.type == ActivityTypes.invoke:
return False
return (
context.activity.type == ActivityTypes.invoke
and context.activity.name == event_name
return event_selector is None or _matches_text(
event_selector, context.activity.name or ""
)

return __selector
Expand Down Expand Up @@ -87,17 +91,7 @@ def __selector(context: TurnContext) -> bool:
"""Selector function that checks if the given channel ID matches the selection criteria."""
if context.activity.channel_id is None:
return False
if norm_channel_id.sub_channel:
# if passed in a sub-channel, the incoming activity must also come from
# the sub-channel
return context.activity.channel_id == norm_channel_id
else:
# if passed in only a parent channel, only check the parent channel of
# the incoming activity
return (
ChannelId.get_channel(context.activity.channel_id)
== norm_channel_id.channel
)
return norm_channel_id.includes(context.activity.channel_id)

return __selector

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import re
from typing import Callable, TypeVar, Awaitable, Protocol

from ..turn_context import TurnContext
Expand All @@ -11,6 +12,7 @@
StateT = TypeVar("StateT", bound=TurnState)
_StateContra = TypeVar("_StateContra", bound=TurnState, contravariant=True)
_RouteHandlerT = TypeVar("_RouteHandlerT", bound=Callable)
TextSelector = str | re.Pattern[str] | list[str | re.Pattern[str]]


class _RouteDecorator(Protocol[_RouteHandlerT]):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import re

from email.message import Message
from urllib.parse import urlparse

from ._type_defs import TextSelector

_CONTENT_TYPE = "Content-Type"


Expand Down Expand Up @@ -34,3 +38,29 @@ def _basic_url_check(url: str) -> bool:
return parsed.scheme == "https" or (
parsed.scheme == "http" and parsed.hostname == "localhost"
)


def _matches_text(select: TextSelector | None = None, text: str | None = None) -> bool:
"""Checks if the given text matches the specified text selector.

:param select: The text selector to match against. Can be a string, a compiled regex pattern, or a list of these.
:param text: The text to check. Defaults to an empty string if None is provided.
:return: True if the text matches the selector, False otherwise.
"""
if select is None:
return True

text = text or ""
if isinstance(select, list):
for item in select:
if isinstance(item, re.Pattern):
if re.fullmatch(item, text) is not None:
return True
elif text == item:
return True
return False

if isinstance(select, re.Pattern):
return re.fullmatch(select, text) is not None

return text == select
Loading
Loading