Skip to content
Merged
7 changes: 7 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
**Release Date:** Unreleased
**Previous Version:** 1.8.0 (Released 2026-10-01)

## Slack

- **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.
- **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.
Expand All @@ -14,6 +20,7 @@
## Bug Fixes

- **Connector User-Agent Headers**: Added the SDK `User-Agent` header to `MCSConnectorClient` and ensured `ConnectorClient`, `TeamsConnectorClient`, and `UserTokenClient` apply it to caller-provided `ClientSession` instances.
- **Teams Meeting Event Models**: Fixed meeting start and end routes to use `MeetingStartEventValue` and `MeetingEndEventValue`, respectively, instead of `MeetingDetails`, ensuring handlers receive correctly typed event data (#619).

---

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

from ._service_set import _ServiceSet

__all__ = ["_ServiceSet"]
# intentionally left empty to avoid dependency issues
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from abc import ABC

from typing import Awaitable, Callable

from microsoft_agents.activity import (
Activity,
ResourceResponse,
ConversationReference,
)

from microsoft_agents.hosting.core.turn_context import (
TurnContext,
OnSendActivitiesHandler,
OnUpdateActivityHandler,
OnDeleteActivityHandler,
)


class _TurnContextWrapper(TurnContext, ABC):
"""A base class for wrapping a TurnContext with additional functionality."""

def __init__(self, context: TurnContext) -> None:
"""Initialise the turn context from a plain turn context.

:param context: The base turn context provided by the core runtime.
"""
super().__init__(context)

self._original = context
self._turn_state = context.turn_state
self.buffered_reply_activities = context.buffered_reply_activities

@property
def responded(self) -> bool:
return self._original.responded

@responded.setter
def responded(self, value: bool):
self._original.responded = value

@property
def streaming_response(self):
return self._original.streaming_response

async def send_activity(
self,
activity_or_text: Activity | str,
speak: str | None = None,
input_hint: str | None = None,
) -> ResourceResponse:
"""
Send an activity to the conversation.

:param activity_or_text: The activity to send or a string representing the text of the activity.
:param speak: Optional speech text for the activity.
:param input_hint: Optional input hint for the activity.
:return: A ResourceResponse representing the result of the send operation.
"""
return await self._original.send_activity(
activity_or_text, speak=speak, input_hint=input_hint
)

async def send_activities(
self, activities: list[Activity]
) -> list[ResourceResponse]:
"""
Send multiple activities to the conversation.

:param activities: A list of activities to send.
:return: A list of ResourceResponse objects representing the result of each send operation.
"""
return await self._original.send_activities(activities)

async def update_activity(self, activity: Activity):
"""
Replaces an existing activity.
:param activity:
:return:
"""
return await self._original.update_activity(activity)

async def delete_activity(self, id_or_reference: str | ConversationReference):
"""
Deletes an existing activity.
:param id_or_reference:
:return:
"""
return await self._original.delete_activity(id_or_reference)

def on_send_activities(self, handler: OnSendActivitiesHandler) -> TurnContext:
"""
Registers a handler to be notified of and potentially intercept the sending of activities.
:param handler: the handler to register
:type handler: OnSendActivitiesHandler
:return:
"""
self._original.on_send_activities(handler)
return self

def on_update_activity(self, handler: OnUpdateActivityHandler) -> TurnContext:
"""
Registers a handler to be notified of and potentially intercept an activity being updated.
:param handler: the handler to register
:type handler: OnUpdateActivityHandler
:return:
"""
self._original.on_update_activity(handler)
return self

def on_delete_activity(self, handler: OnDeleteActivityHandler) -> TurnContext:
"""
Registers a handler to be notified of and potentially intercept an activity being deleted.
:param handler: the handler to register
:type handler: OnDeleteActivityHandler
:return:
"""
self._original.on_delete_activity(handler)
return self

async def send_trace_activity(
self,
name: str,
value: object = None,
value_type: str | None = None,
label: str | None = None,
) -> ResourceResponse:
return await self._original.send_trace_activity(
name=name, value=value, value_type=value_type, label=label
)

async def _aclose(self) -> None:
"""
Asynchronously close any resources associated with the turn context.

:return: None
"""
await self._original._aclose()

def _on_aclose(self, handler: Callable[[TurnContext], Awaitable[None]]) -> None:
"""
Register a handler to be called when the turn context is asynchronously closed.

:param handler: A callable that takes the turn context and returns an awaitable.
:return: None
"""
self._original._on_aclose(handler)
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"InputFileDownloader",
"Query",
"Route",
"RouteRank",
"RouteHandler",
"TypingChannelStrategy",
"TypingIndicator",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
InputHints,
ResourceResponse,
)
from microsoft_agents.hosting.core._utils._turn_context_wrapper import (
_TurnContextWrapper,
)
from microsoft_agents.hosting.core import (
AgentApplication,
TurnContext,
Expand All @@ -35,7 +38,7 @@
from ._utils import _apply_prompt_preview_normalizer, _is_recipient_targeted


class TeamsTurnContext(TurnContext):
class TeamsTurnContext(_TurnContextWrapper):
"""A context object for handling Teams-specific turn functionality.

Wraps a plain :class:`TurnContext` so that Teams-aware route handlers
Expand All @@ -50,9 +53,6 @@ def __init__(self, context: TurnContext, app: AgentApplication) -> None:
"""
super().__init__(context)
self._app = app
self._turn_state = context.turn_state

self._original = context

self._set_teams_activity()

Expand All @@ -76,18 +76,6 @@ def _set_teams_activity(self) -> None:
self._activity.__class__ = TeamsActivity
self._teams_activity = cast(TeamsActivity, self._activity)

@property
def responded(self) -> bool:
return self._original.responded

@responded.setter
def responded(self, value: bool):
self._original.responded = value

@property
def streaming_response(self):
return self._original.streaming_response

@property
def activity(self) -> TeamsActivity:
"""The current activity, typed as a :class:`TeamsActivity`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
Licensed under the MIT License.
"""

from .slack_activity import SlackActivity
from .slack_agent_extension import SlackAgentExtension
from .slack_turn_context import SlackTurnContext
from .slack_helpers import (
create_conversation_id,
slack_bot_id_from_conversation_id,
Expand All @@ -15,7 +17,9 @@
)

__all__ = [
"SlackActivity",
"SlackAgentExtension",
"SlackTurnContext",
"create_conversation_id",
"slack_bot_id_from_conversation_id",
"slack_channel_id_from_conversation_id",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

from __future__ import annotations

from typing import Any, Optional
from typing import Any


def _parse_path(path: str) -> Optional[list[int | str]]:
def _parse_path(path: str) -> list[int | str] | None:
"""Tokenize a dot/bracket path into a list of segments.

Supports ``a.b.c``, ``a[0].b``, ``a[1][2]``. Integer brackets become ``int``
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from typing import Any, Optional
from typing import Any

from pydantic import ConfigDict

Expand All @@ -20,7 +20,7 @@ class ActionPayload(SlackModel):

model_config = ConfigDict(extra="allow", populate_by_name=True)

type: Optional[str] = None
channel: Optional[Any] = None
message: Optional[Any] = None
actions: Optional[Any] = None
type: str | None = None
channel: Any | None = None
message: Any | None = None
actions: Any | None = None
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from __future__ import annotations

from typing import Any, Optional
from typing import Any

from pydantic import BaseModel, ConfigDict, Field

Expand Down Expand Up @@ -66,9 +66,9 @@ class TaskUpdateChunk(BaseModel):
id: str
title: str
status: str = SlackTaskStatus.IN_PROGRESS
details: Optional[str] = None
output: Optional[str] = None
sources: Optional[list[Source]] = None
details: str | None = None
output: str | None = None
sources: list[Source] | None = None


# Type alias for any chunk variant.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

from __future__ import annotations

from typing import Optional

from pydantic import ConfigDict

from .slack_model import SlackModel
Expand All @@ -27,19 +25,19 @@ class EventContent(SlackModel):
model_config = ConfigDict(extra="allow", populate_by_name=True)

# ── Common event fields (https://docs.slack.dev/apis/events-api/#event-type-structure)
type: Optional[str] = None
event_ts: Optional[str] = None
user: Optional[str] = None
ts: Optional[str] = None
subtype: Optional[str] = None
channel: Optional[str] = None
channel_type: Optional[str] = None
team: Optional[str] = None
type: str | None = None
event_ts: str | None = None
user: str | None = None
ts: str | None = None
subtype: str | None = None
channel: str | None = None
channel_type: str | None = None
team: str | None = None

# ── message event fields ──
text: Optional[str] = None
client_msg_id: Optional[str] = None
text: str | None = None
client_msg_id: str | None = None

# ── reaction_added / reaction_removed event fields ──
reaction: Optional[str] = None
item_user: Optional[str] = None
reaction: str | None = None
item_user: str | None = None
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

from typing import Any, Optional
from typing import Any

from pydantic import ConfigDict, Field

Expand All @@ -31,21 +31,21 @@ class EventEnvelope(SlackModel):

model_config = ConfigDict(extra="allow", populate_by_name=True)

token: Optional[str] = None
team_id: Optional[str] = None
context_team_id: Optional[str] = None
context_enterprise_id: Optional[str] = None
api_app_id: Optional[str] = None
token: str | None = None
team_id: str | None = None
context_team_id: str | None = None
context_enterprise_id: str | None = None
api_app_id: str | None = None

# Python name `event_content` ↔ JSON field `event`
event_content: Optional[EventContent] = Field(default=None, alias="event")

type: Optional[str] = None
event_id: Optional[str] = None
event_time: Optional[int] = None
authorizations: Optional[Any] = None
is_ext_shared_channel: Optional[bool] = None
event_context: Optional[str] = None
event_content: EventContent | None = Field(default=None, alias="event")

type: str | None = None
event_id: str | None = None
event_time: int | None = None
authorizations: Any | None = None
is_ext_shared_channel: bool | None = None
event_context: str | None = None

def _normalize_path(self, path: str) -> str:
"""Map the C# property alias ``event_content`` to JSON field ``event``."""
Expand Down
Loading
Loading