diff --git a/.azdo/ci-pr.yaml b/.azdo/ci-pr.yaml index 441692c0..b2edd6b4 100644 --- a/.azdo/ci-pr.yaml +++ b/.azdo/ci-pr.yaml @@ -78,6 +78,7 @@ steps: else echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" fi + python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl displayName: 'Install wheels' diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 17475f1e..0651393f 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -67,6 +67,7 @@ jobs: else echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+" fi + python -m pip install ./dist/microsoft_agents_hosting_slack*.whl python -m pip install ./dist/microsoft_agents_storage_blob*.whl python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl - name: Test with pytest diff --git a/libraries/microsoft-agents-hosting-slack/LICENSE b/libraries/microsoft-agents-hosting-slack/LICENSE new file mode 100644 index 00000000..9e841e7a --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/libraries/microsoft-agents-hosting-slack/MANIFEST.in b/libraries/microsoft-agents-hosting-slack/MANIFEST.in new file mode 100644 index 00000000..74282fce --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/MANIFEST.in @@ -0,0 +1 @@ +include VERSION.txt diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/__init__.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/__init__.py new file mode 100644 index 00000000..948f4e67 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/__init__.py @@ -0,0 +1,26 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .slack_agent_extension import SlackAgentExtension +from .slack_helpers import ( + create_conversation_id, + slack_bot_id_from_conversation_id, + slack_channel_id_from_conversation_id, + slack_decode, + slack_encode, + slack_team_id_from_conversation_id, + slack_thread_ts_from_conversation_id, +) + +__all__ = [ + "SlackAgentExtension", + "create_conversation_id", + "slack_bot_id_from_conversation_id", + "slack_channel_id_from_conversation_id", + "slack_decode", + "slack_encode", + "slack_team_id_from_conversation_id", + "slack_thread_ts_from_conversation_id", +] diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py new file mode 100644 index 00000000..abd3879a --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/_path_navigator.py @@ -0,0 +1,108 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Any, Optional, Tuple + + +def _parse_path(path: str) -> Optional[list[Any]]: + """Tokenize a dot/bracket path into a list of segments. + + Supports ``a.b.c``, ``a[0].b``, ``a[1][2]``. Integer brackets become ``int`` + segments; everything else becomes a string segment. Returns ``None`` for + malformed bracket nesting. + """ + if not path: + return [] + + segments: list[Any] = [] + i = 0 + start = 0 + + def emit() -> None: + nonlocal start + if start < i: + segments.append(path[start:i]) + start = i + 1 + + while i < len(path): + ch = path[i] + if ch == ".": + emit() + elif ch == "[": + emit() + nesting = 1 + i += 1 + inner_start = i + while i < len(path): + c = path[i] + if c == "[": + nesting += 1 + elif c == "]": + nesting -= 1 + if nesting == 0: + break + i += 1 + if nesting != 0: + return None + inner = path[inner_start:i] + if inner.isdigit() or (inner.startswith("-") and inner[1:].isdigit()): + segments.append(int(inner)) + else: + segments.append(inner) + start = i + 1 + i += 1 + + emit() + return segments + + +def _resolve_segment(current: Any, segment: Any) -> Tuple[bool, Any]: + """Resolve one path segment against the current node. + + Returns ``(found, value)``. ``found`` is False when the segment cannot be + resolved (missing key, out-of-range index, primitive node). + """ + if current is None: + return False, None + + if isinstance(segment, int): + if isinstance(current, (list, tuple)): + if -len(current) <= segment < len(current): + return True, current[segment] + return False, None + return False, None + + # string segment → dict key (case-sensitive fast path, case-insensitive fallback) + if isinstance(current, dict): + if segment in current: + return True, current[segment] + lower = segment.lower() + for key, value in current.items(): + if isinstance(key, str) and key.lower() == lower: + return True, value + return False, None + + return False, None + + +def try_get_path_value(data: Any, path: str) -> Tuple[bool, Any]: + """Walk ``path`` against ``data``. Returns ``(found, value)``.""" + if data is None: + return False, None + if not path: + return True, data + + segments = _parse_path(path) + if segments is None: + return False, None + + current: Any = data + for segment in segments: + found, current = _resolve_segment(current, segment) + if not found: + return False, None + return True, current diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/__init__.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/__init__.py new file mode 100644 index 00000000..79218e7c --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/__init__.py @@ -0,0 +1,42 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .action_payload import ActionPayload +from .chunks import ( + BlocksChunk, + Chunk, + MarkdownTextChunk, + SlackTaskStatus, + Source, + TaskDisplayMode, + TaskUpdateChunk, +) +from .event_content import EventContent +from .event_envelope import EventEnvelope +from .slack_api import SLACK_API_BASE, SlackApi +from .slack_channel_data import SlackChannelData +from .slack_model import SlackModel +from .slack_response import SlackResponse, SlackResponseException +from .slack_stream import SlackStream + +__all__ = [ + "ActionPayload", + "BlocksChunk", + "Chunk", + "EventContent", + "EventEnvelope", + "MarkdownTextChunk", + "SLACK_API_BASE", + "SlackApi", + "SlackChannelData", + "SlackModel", + "SlackResponse", + "SlackResponseException", + "SlackStream", + "SlackTaskStatus", + "Source", + "TaskDisplayMode", + "TaskUpdateChunk", +] diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/action_payload.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/action_payload.py new file mode 100644 index 00000000..fd7a21e0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/action_payload.py @@ -0,0 +1,26 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import ConfigDict + +from .slack_model import SlackModel + + +class ActionPayload(SlackModel): + """ + Interactive Message / Block Kit action payload from Slack. Sent when a user + clicks a button or interacts with a block element. + """ + + 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 diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/chunks.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/chunks.py new file mode 100644 index 00000000..1473660d --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/chunks.py @@ -0,0 +1,77 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Streaming chunk shapes for Slack ``chat.appendStream`` / ``chat.stopStream``. +See https://docs.slack.dev/reference/methods/chat.appendStream +""" + +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class SlackTaskStatus: + """Status values accepted by :class:`TaskUpdateChunk`.""" + + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETE = "complete" + ERROR = "error" + + +class TaskDisplayMode: + """Values accepted by ``chat.startStream``'s ``task_display_mode``.""" + + PLAN = "plan" + TIMELINE = "timeline" + + +class Source(BaseModel): + """Citation/source attached to a :class:`TaskUpdateChunk`.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + type: str = "url" + url: str = "" + text: str = "" + + +class MarkdownTextChunk(BaseModel): + """Append a chunk of markdown-formatted text to a Slack stream.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + type: str = Field(default="markdown_text", frozen=True) + text: str = "" + + +class BlocksChunk(BaseModel): + """Append a chunk of Block Kit blocks to a Slack stream.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + type: str = Field(default="blocks", frozen=True) + blocks: list[Any] = Field(default_factory=list) + + +class TaskUpdateChunk(BaseModel): + """Append a task-status update chunk to a Slack stream.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + type: str = Field(default="task_update", frozen=True) + id: str + title: str + status: str = SlackTaskStatus.IN_PROGRESS + details: Optional[str] = None + output: Optional[str] = None + sources: Optional[list[Source]] = None + + +# Type alias for any chunk variant. +Chunk = ( + BaseModel # all chunk classes are BaseModel subclasses with a `type` discriminator +) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_content.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_content.py new file mode 100644 index 00000000..3adda2e7 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_content.py @@ -0,0 +1,45 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import ConfigDict + +from .slack_model import SlackModel + + +class EventContent(SlackModel): + """ + The inner ``event`` object from a Slack Events API callback payload. + + Slack calls this the "event content". Because event payloads vary so widely + by ``type``, every unmodelled field is preserved via Pydantic's + ``extra="allow"`` and is reachable through :meth:`SlackModel.get` using the + same snake_case names shown in the Slack docs. + + See https://docs.slack.dev/reference/events + """ + + 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 + + # ── message event fields ── + text: Optional[str] = None + client_msg_id: Optional[str] = None + + # ── reaction_added / reaction_removed event fields ── + reaction: Optional[str] = None + item_user: Optional[str] = None diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_envelope.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_envelope.py new file mode 100644 index 00000000..9883b17b --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/event_envelope.py @@ -0,0 +1,56 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import ConfigDict, Field + +from .event_content import EventContent +from .slack_model import SlackModel + + +class EventEnvelope(SlackModel): + """ + Outer envelope for a Slack Events API callback. Contains workspace, application, + and authorization context, plus the inner event payload (``event_content``). + + See https://docs.slack.dev/apis/events-api/#callback-field. + + Path navigation supports both the Slack JSON prefix ``event.`` and the Python + property prefix ``event_content.`` interchangeably:: + + envelope = SlackChannelData.from_activity(turn_context.activity).envelope + workspace_id = envelope.get("team_id") + channel = envelope.get("event.channel") + block_type = envelope.get("event.blocks[0].type") + """ + + 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 + + # 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 + + def _normalize_path(self, path: str) -> str: + """Map the C# property alias ``event_content`` to JSON field ``event``.""" + if path.lower() == "event_content": + return "event" + if path.lower().startswith("event_content."): + return "event" + path[len("event_content") :] + return path diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py new file mode 100644 index 00000000..529089e0 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_api.py @@ -0,0 +1,120 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import json +from typing import Any, Optional + +from aiohttp import ClientSession, ClientTimeout +from pydantic import BaseModel + +from .slack_response import SlackResponse, SlackResponseException + +SLACK_API_BASE = "https://slack.com/api" + + +def _serialize_options(options: Any) -> str: + """Serialize the ``options`` argument to a JSON string suitable for Slack. + + - ``None`` becomes ``"{}"``. + - A pre-built JSON string is returned as-is. + - A Pydantic model is dumped with ``exclude_none=True`` to match Slack's + tolerance of omitted fields and the C# implementation's null-stripping. + - Anything else is passed through :func:`json.dumps`, after recursively + removing ``None`` values from dicts/lists. + """ + if options is None: + return "{}" + if isinstance(options, str): + return options + if isinstance(options, BaseModel): + return options.model_dump_json(by_alias=True, exclude_none=True) + return json.dumps(_strip_nones(options), ensure_ascii=False) + + +def _strip_nones(value: Any) -> Any: + if isinstance(value, dict): + return {k: _strip_nones(v) for k, v in value.items() if v is not None} + if isinstance(value, list): + return [_strip_nones(v) for v in value] + if isinstance(value, BaseModel): + return value.model_dump(mode="json", by_alias=True, exclude_none=True) + return value + + +class SlackApi: + """ + Async HTTP client for the Slack Web API. + + Mirrors the C# ``SlackApi``: every call is a ``POST`` to + ``https://slack.com/api/{method}`` with a JSON body and an optional bearer + token. The response body is parsed into a :class:`SlackResponse`; a + :class:`SlackResponseException` is raised on a non-2xx HTTP status or when + Slack returns ``ok=false``. + """ + + def __init__( + self, + session: Optional[ClientSession] = None, + *, + base_url: str = SLACK_API_BASE, + request_timeout: float = 30.0, + ) -> None: + self._session = session + self._owns_session = session is None + self._base_url = base_url + self._timeout = ClientTimeout(total=request_timeout) + + async def call( + self, + method: str, + options: Any = None, + token: str = "", + ) -> SlackResponse: + """Invoke a Slack Web API method. + + :param method: The API method name (e.g. ``"chat.postMessage"``). + :param options: Request payload. May be ``None``, a JSON string, a + ``dict`` / ``list``, or a Pydantic model. + :param token: Bearer token. Sent as ``Authorization: Bearer {token}`` + when non-empty. + :returns: The parsed :class:`SlackResponse`. + :raises ValueError: if ``method`` is empty or whitespace. + :raises SlackResponseException: on HTTP error or ``ok=false``. + """ + if not method or not method.strip(): + raise ValueError("method must be a non-empty string") + + body = _serialize_options(options) + url = f"{self._base_url}/{method}" + headers = {"Content-Type": "application/json"} + if token and token.strip(): + headers["Authorization"] = f"Bearer {token}" + + session = self._session or ClientSession() + try: + async with session.post( + url, data=body, headers=headers, timeout=self._timeout + ) as response: + text = await response.text() + try: + payload = json.loads(text) if text else {} + data = SlackResponse.model_validate(payload) + except Exception as exc: + raise SlackResponseException( + f"Slack API error on {method} (HTTP {response.status}):\n{text}" + ) from exc + + if not response.ok or not data.ok: + raise SlackResponseException( + f"Slack API error on {method} (HTTP {response.status}):\n{text}", + data, + ) + + return data + finally: + if self._owns_session: + await session.close() diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py new file mode 100644 index 00000000..eb28fcd2 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_channel_data.py @@ -0,0 +1,67 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict, Field + +from .action_payload import ActionPayload +from .event_envelope import EventEnvelope + + +class SlackChannelData(BaseModel): + """ + Data associated with a Slack channel activity, as delivered by Azure Bot + Service in :attr:`Activity.channel_data`. + + Bot Service historically named the envelope property ``SlackMessage`` and + the API token ``ApiToken``; this model accepts both the PascalCase JSON + names (via aliases) and snake_case Python names. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + envelope: Optional[EventEnvelope] = Field(default=None, alias="SlackMessage") + payload: Optional[ActionPayload] = Field(default=None, alias="Payload") + api_token: Optional[str] = Field(default=None, alias="ApiToken") + + @property + def channel(self) -> Optional[str]: + """Slack channel id, sourced from the envelope or action payload.""" + if self.envelope is not None: + return self.envelope.get("event.channel") + if self.payload is not None: + return self.payload.get("channel") + return None + + @property + def thread_ts(self) -> Optional[str]: + """Thread timestamp, sourced from the envelope event or payload message.""" + if self.envelope is not None: + return self.envelope.get("event.thread_ts") or self.envelope.get("event.ts") + if self.payload is not None: + return self.payload.get("message.thread_ts") or self.payload.get( + "message.ts" + ) + return None + + @classmethod + def from_activity(cls, activity: Any) -> "SlackChannelData": + """Build a :class:`SlackChannelData` from an Activity's ``channel_data``. + + Accepts ``Activity.channel_data`` that is either a ``dict`` (typical for + deserialized incoming requests) or already a :class:`SlackChannelData`. + Returns an empty instance when ``channel_data`` is missing. + """ + data = getattr(activity, "channel_data", None) if activity is not None else None + if data is None: + return cls() + if isinstance(data, cls): + return data + if isinstance(data, BaseModel): + data = data.model_dump(mode="json", by_alias=True) + return cls.model_validate(data) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py new file mode 100644 index 00000000..0039390c --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_model.py @@ -0,0 +1,62 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Any, Optional, Type, TypeVar + +from pydantic import BaseModel, ConfigDict + +from .._path_navigator import try_get_path_value + +T = TypeVar("T") + + +class SlackModel(BaseModel): + """ + Base class for Slack model objects that expose dot-notation path navigation via + :meth:`get` and :meth:`try_get`. + + Subclasses are Pydantic models with ``extra="allow"`` so any unmodelled fields + from Slack are preserved and reachable by path. The path navigator walks the + serialized form (``by_alias=True``), so paths use the same snake_case names + that appear in the Slack docs. + + Subclasses whose JSON field names differ from their Python property names + override :meth:`_normalize_path` to remap the alias before navigation (e.g. + :class:`EventEnvelope` maps ``event_content`` → ``event``). + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + def _data(self) -> dict[str, Any]: + """Return the serialized form used for path navigation. Subclasses with + their own backing store may override this.""" + return self.model_dump(mode="json", by_alias=True, exclude_none=False) + + def _normalize_path(self, path: str) -> str: + """Remap caller-supplied path before navigation. Default: identity.""" + return path + + def get(self, path: str, default: Optional[T] = None, type_: Type[T] = None) -> Any: + """Get a value at the dot-notation ``path``. Supports dot separators and + bracket array indexing (e.g. ``"message.attachments[0].text"``). Returns + ``default`` (or ``None``) when the path does not exist. + + ``type_`` is accepted for API symmetry with the C# generic ``Get`` but + is not enforced at runtime — Pydantic models keep values in their + deserialized shape. + """ + if not path: + return self._data() + + found, value = try_get_path_value(self._data(), self._normalize_path(path)) + return value if found else default + + def try_get(self, path: str) -> tuple[bool, Any]: + """Like :meth:`get`, but returns ``(found, value)``.""" + if not path: + return True, self._data() + return try_get_path_value(self._data(), self._normalize_path(path)) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_response.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_response.py new file mode 100644 index 00000000..bc719fb7 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_response.py @@ -0,0 +1,42 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from pydantic import ConfigDict + +from .slack_model import SlackModel + + +class SlackResponse(SlackModel): + """ + Response from a Slack Web API call. + + Named properties cover the fields common to every Slack response. Any + additional fields returned by a specific method are accessible via + :meth:`SlackModel.get` using dot-notation paths:: + + response = await slack_api.call("chat.postMessage", options, token) + channel = response.get("channel") + msg_ts = response.get("message.ts") + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + ok: bool = False + error: Optional[str] = None + warning: Optional[str] = None + ts: Optional[str] = None + response_metadata: Optional[Any] = None + + +class SlackResponseException(Exception): + """Raised when a Slack Web API call returns a non-2xx status or ``ok=false``.""" + + def __init__(self, message: str, response: Optional[SlackResponse] = None) -> None: + super().__init__(message) + self.response = response diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py new file mode 100644 index 00000000..c9cd348e --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/api/slack_stream.py @@ -0,0 +1,154 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import json +from typing import Any, Optional, Sequence, Union + +from pydantic import BaseModel + +from .chunks import MarkdownTextChunk, TaskDisplayMode +from .slack_api import SlackApi + + +def _chunk_to_dict(chunk: Any) -> Any: + if isinstance(chunk, BaseModel): + return chunk.model_dump(mode="json", by_alias=True, exclude_none=True) + return chunk + + +class SlackStream: + """ + Incrementally builds and updates a single Slack message via + ``chat.startStream`` / ``chat.appendStream`` / ``chat.stopStream``. + + Not thread-safe; concurrent operations on the same instance produce + undefined behavior. Call :meth:`start` before :meth:`append`, and + :meth:`stop` when finished. + """ + + def __init__( + self, + slack_api: SlackApi, + channel: str, + thread_ts: str, + token: str, + ) -> None: + self._slack_api = slack_api + self._channel = channel + self._thread_ts = thread_ts + self._token = token + self._message_ts: Optional[str] = None + + async def start( + self, task_display_mode: str = TaskDisplayMode.PLAN + ) -> "SlackStream": + """Start a new Slack stream. + + See https://docs.slack.dev/reference/methods/chat.startStream + """ + result = await self._slack_api.call( + "chat.startStream", + { + "channel": self._channel, + "thread_ts": self._thread_ts, + "task_display_mode": task_display_mode, + }, + self._token, + ) + self._message_ts = result.ts + return self + + async def append( + self, + chunk_or_text: Union[str, BaseModel, Sequence[BaseModel]], + ) -> "SlackStream": + """Append one or more chunks to the stream. + + Accepts a plain string (wrapped in a :class:`MarkdownTextChunk`), a + single chunk model, or an iterable of chunk models. + + See https://docs.slack.dev/reference/methods/chat.appendStream + """ + if chunk_or_text is None: + raise ValueError("chunk_or_text must not be None") + + if isinstance(chunk_or_text, str): + chunks: list[Any] = [MarkdownTextChunk(text=chunk_or_text)] + elif isinstance(chunk_or_text, BaseModel): + chunks = [chunk_or_text] + else: + chunks = list(chunk_or_text) + + if not chunks: + return self + + await self._slack_api.call( + "chat.appendStream", + { + "channel": self._channel, + "ts": self._message_ts, + "thread_ts": self._thread_ts, + "chunks": [_chunk_to_dict(c) for c in chunks], + }, + self._token, + ) + return self + + async def stop( + self, + chunks: Optional[Sequence[BaseModel]] = None, + blocks: Union[str, Sequence[Any], dict, None] = None, + ) -> None: + """Stop the active stream, optionally finalizing with chunks and/or + Block Kit blocks. + + ``blocks`` may be a JSON-array string, a JSON-object string containing + a ``"blocks"`` array, a Python list of block dicts, or a dict with a + top-level ``"blocks"`` key. + + See https://docs.slack.dev/reference/methods/chat.stopStream + """ + if not self._message_ts: + return + + resolved_blocks = self._resolve_blocks(blocks) + resolved_chunks = ( + [_chunk_to_dict(c) for c in chunks] if chunks is not None else None + ) + + body: dict[str, Any] = { + "channel": self._channel, + "ts": self._message_ts, + "thread_ts": self._thread_ts, + } + if resolved_chunks is not None: + body["chunks"] = resolved_chunks + if resolved_blocks is not None: + body["blocks"] = resolved_blocks + + await self._slack_api.call("chat.stopStream", body, self._token) + + @staticmethod + def _resolve_blocks(blocks: Any) -> Optional[list[Any]]: + if blocks is None: + return None + if isinstance(blocks, str): + try: + parsed = json.loads(blocks) + except json.JSONDecodeError as exc: + raise ValueError("blocks string is not valid JSON") from exc + return SlackStream._resolve_blocks(parsed) + if isinstance(blocks, dict): + if "blocks" not in blocks or not isinstance(blocks["blocks"], list): + raise ValueError("blocks object must contain a 'blocks' array property") + return blocks["blocks"] + if isinstance(blocks, list): + return blocks + raise ValueError( + "blocks must be a JSON array, a JSON object with a 'blocks' array, " + "or a string encoding either" + ) diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py new file mode 100644 index 00000000..29313d59 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_agent_extension.py @@ -0,0 +1,202 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import re +from typing import Any, Callable, Generic, Optional, Pattern, TypeVar, Union + +from microsoft_agents.activity import ActivityTypes, Channels +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.app import AgentApplication, RouteRank +from microsoft_agents.hosting.core.app.state import TurnState + +from .api import ( + SlackApi, + SlackChannelData, + SlackResponse, + SlackStream, +) + +StateT = TypeVar("StateT", bound=TurnState) + +TextSelector = Union[str, Pattern[str], None] + +_SLACK_API_SERVICE_KEY = "microsoft_agents.hosting.slack.SlackApi" + + +def _matches_text(selector: TextSelector, text: Optional[str]) -> bool: + if selector is None: + return True + if text is None: + return False + if isinstance(selector, Pattern): + return re.fullmatch(selector, text) is not None + return text == selector + + +def _is_slack_channel(context: TurnContext) -> bool: + return context.activity.channel_id == Channels.slack.value + + +class SlackAgentExtension(Generic[StateT]): + """ + Slack-specific route registration and helpers for an + :class:`~microsoft_agents.hosting.core.app.AgentApplication`. + + Usage:: + + from microsoft_agents.hosting.slack import SlackAgentExtension + from microsoft_agents.hosting.slack.api import SlackChannelData + + app = AgentApplication(options) + slack = SlackAgentExtension(app) + + @slack.on_message("hello") + async def greet(context, state): + channel_data = SlackChannelData.from_activity(context.activity) + await slack.call( + context, + "chat.postMessage", + { + "channel": channel_data.channel, + "text": f"Hi, {context.activity.from_property.name}!", + }, + token=channel_data.api_token, + ) + """ + + def __init__( + self, + application: AgentApplication[StateT], + *, + slack_api: Optional[SlackApi] = None, + ) -> None: + self._app = application + self._slack_api = slack_api or SlackApi() + + @property + def slack_api(self) -> SlackApi: + """The shared :class:`SlackApi` used by :meth:`call` and :meth:`create_stream`.""" + return self._slack_api + + # ── direct Slack API access ──────────────────────────────────────────── + + async def call( + self, + turn_context: TurnContext, + method: str, + options: Any = None, + token: str = "", + ) -> SlackResponse: + """Invoke a Slack Web API method, preferring a per-turn :class:`SlackApi` + if one has been cached on ``turn_context.services``.""" + api = self._slack_api + if turn_context is not None and turn_context.has(_SLACK_API_SERVICE_KEY): + api = turn_context.get(_SLACK_API_SERVICE_KEY) # type: ignore[assignment] + return await api.call(method, options, token) + + async def create_stream( + self, + turn_context: TurnContext, + thread_ts: Optional[str] = None, + ) -> SlackStream: + """Create and start a :class:`SlackStream` for the current Slack thread.""" + channel_data = SlackChannelData.from_activity(turn_context.activity) + if channel_data.envelope is None: + raise ValueError( + "create_stream requires a Slack event envelope on the activity" + ) + resolved_thread_ts = thread_ts or channel_data.envelope.get("event.ts") + api = self._slack_api + if turn_context.has(_SLACK_API_SERVICE_KEY): + api = turn_context.get(_SLACK_API_SERVICE_KEY) # type: ignore[assignment] + stream = SlackStream( + api, + channel_data.envelope.get("event.channel"), + resolved_thread_ts, + channel_data.api_token or "", + ) + return await stream.start() + + # ── message routes ───────────────────────────────────────────────────── + + def on_message( + self, + select: TextSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Slack message activities. + + When ``select`` is ``None``, every Slack message matches; otherwise the + activity's ``text`` must equal ``select`` (string) or fully match it + (compiled regex). + + ``rank`` defaults to :attr:`RouteRank.DEFAULT`; when ``select`` is + ``None`` the rank is downgraded to :attr:`RouteRank.LAST` so explicit + text routes win — matching the C# behavior. + """ + effective_rank = ( + RouteRank.LAST if (select is None and rank == RouteRank.DEFAULT) else rank + ) + + def __selector(context: TurnContext) -> bool: + if context.activity.type != ActivityTypes.message or not _is_slack_channel( + context + ): + return False + return _matches_text(select, context.activity.text) + + def __call(func: Callable) -> Callable: + self._app.add_route( + __selector, + func, + rank=effective_rank, + auth_handlers=auth_handlers, + ) + return func + + return __call + + # ── event routes ─────────────────────────────────────────────────────── + + def on_event( + self, + event_name: TextSelector = None, + *, + auth_handlers: Optional[list[str]] = None, + rank: RouteRank = RouteRank.DEFAULT, + ) -> Callable: + """Register a handler for Slack event activities. + + When ``event_name`` is ``None``, every Slack event matches; otherwise + the activity's ``name`` must equal it (string) or fully match it + (compiled regex). + """ + effective_rank = ( + RouteRank.LAST + if (event_name is None and rank == RouteRank.DEFAULT) + else rank + ) + + def __selector(context: TurnContext) -> bool: + if context.activity.type != ActivityTypes.event or not _is_slack_channel( + context + ): + return False + return _matches_text(event_name, context.activity.name) + + def __call(func: Callable) -> Callable: + self._app.add_route( + __selector, + func, + rank=effective_rank, + auth_handlers=auth_handlers, + ) + return func + + return __call diff --git a/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_helpers.py b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_helpers.py new file mode 100644 index 00000000..b2f4ce4f --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/microsoft_agents/hosting/slack/slack_helpers.py @@ -0,0 +1,61 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Optional + + +def slack_encode(value: Optional[str]) -> Optional[str]: + """Encode text for Slack. See https://api.slack.com/docs/message-formatting.""" + if value is None: + return None + return value.replace("&", "&").replace("<", "<").replace(">", ">") + + +def slack_decode(value: Optional[str]) -> Optional[str]: + """Decode text from Slack. See https://api.slack.com/docs/message-formatting.""" + if value is None: + return None + return value.replace("&", "&").replace("<", "<").replace(">", ">") + + +def create_conversation_id( + slack_bot_id: str, + slack_team_id: str, + slack_channel_id: str, + slack_thread_ts: Optional[str] = None, +) -> str: + """Compose a Bot Service-compatible conversation id from Slack identifiers.""" + if slack_thread_ts is None: + return f"{slack_bot_id}:{slack_team_id}:{slack_channel_id}" + return f"{slack_bot_id}:{slack_team_id}:{slack_channel_id}:{slack_thread_ts}" + + +def _from_conversation_id(conversation_id: str, pos: int) -> Optional[str]: + if conversation_id is None or not conversation_id.strip(): + raise ValueError("conversation_id must be a non-empty string") + parts = conversation_id.split(":") + if len(parts) not in (3, 4): + raise ValueError(f"Invalid conversation_id: {conversation_id}") + if pos >= len(parts): + return None + return parts[pos] + + +def slack_bot_id_from_conversation_id(conversation_id: str) -> Optional[str]: + return _from_conversation_id(conversation_id, 0) + + +def slack_team_id_from_conversation_id(conversation_id: str) -> Optional[str]: + return _from_conversation_id(conversation_id, 1) + + +def slack_channel_id_from_conversation_id(conversation_id: str) -> Optional[str]: + return _from_conversation_id(conversation_id, 2) + + +def slack_thread_ts_from_conversation_id(conversation_id: str) -> Optional[str]: + return _from_conversation_id(conversation_id, 3) diff --git a/libraries/microsoft-agents-hosting-slack/pyproject.toml b/libraries/microsoft-agents-hosting-slack/pyproject.toml new file mode 100644 index 00000000..79d2db73 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-hosting-slack" +dynamic = ["version", "dependencies"] +description = "Integration library for Microsoft Agents with Slack" +readme = {file = "readme.md", content-type = "text/markdown"} +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Operating System :: OS Independent", +] + +[project.urls] +"Homepage" = "https://github.com/microsoft/Agents" diff --git a/libraries/microsoft-agents-hosting-slack/readme.md b/libraries/microsoft-agents-hosting-slack/readme.md new file mode 100644 index 00000000..27fc3042 --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/readme.md @@ -0,0 +1,49 @@ +# Microsoft Agents Hosting - Slack + +[![PyPI version](https://img.shields.io/pypi/v/microsoft-agents-hosting-slack)](https://pypi.org/project/microsoft-agents-hosting-slack/) + +Integration library for building Slack agents using the Microsoft 365 Agents SDK. Provides direct-to-Slack responses (the full Slack Web API surface, beyond what Azure Bot Service exposes), a typed `SlackChannelData` envelope with dot-notation property access, and a `SlackStream` helper for `chat.startStream` / `chat.appendStream` / `chat.stopStream`. + +## Installation + +```bash +pip install microsoft-agents-hosting-slack +``` + +## Usage + +```python +from microsoft_agents.hosting.core.app import AgentApplication +from microsoft_agents.hosting.slack import SlackAgentExtension +from microsoft_agents.hosting.slack.api import SlackChannelData + +app = AgentApplication(options) +slack = SlackAgentExtension(app) + +@slack.on_message() +async def on_slack_message(context, state): + channel_data = SlackChannelData.from_activity(context.activity) + await slack.call( + context, + "chat.postMessage", + { + "channel": channel_data.get("event.channel"), + "text": f"You said: {context.activity.text}", + }, + token=channel_data.api_token, + ) +``` + +## Key Classes + +- **`SlackAgentExtension`** — registers Slack-channel-scoped message/event handlers; exposes `call(...)` and `create_stream(...)`. +- **`SlackChannelData`** — typed wrapper around Bot Service's Slack channel-data payload with `get(path)` / `try_get(path)` accessors. +- **`SlackApi`** — async HTTP client for the Slack Web API. +- **`SlackStream`** — wraps Slack's streaming methods for incremental message updates. +- **`SlackHelpers`** — encode/decode and conversation-id parsing utilities. + +# Quick Links + +- 📦 [All SDK Packages on PyPI](https://pypi.org/search/?q=microsoft-agents) +- 📖 [Complete Documentation](https://aka.ms/agents) +- 🐛 [Report Issues](https://github.com/microsoft/Agents-for-python/issues) diff --git a/libraries/microsoft-agents-hosting-slack/setup.py b/libraries/microsoft-agents-hosting-slack/setup.py new file mode 100644 index 00000000..f7a4e1bb --- /dev/null +++ b/libraries/microsoft-agents-hosting-slack/setup.py @@ -0,0 +1,18 @@ +from os import environ, path +from setuptools import setup + +# Try to read from VERSION.txt file first, fall back to environment variable +version_file = path.join(path.dirname(__file__), "VERSION.txt") +if path.exists(version_file): + with open(version_file, "r", encoding="utf-8") as f: + package_version = f.read().strip() +else: + package_version = environ.get("PackageVersion", "0.0.0") + +setup( + version=package_version, + install_requires=[ + f"microsoft-agents-hosting-core=={package_version}", + "aiohttp>=3.11.11", + ], +) diff --git a/test_samples/extensions/slack-agent/README.md b/test_samples/extensions/slack-agent/README.md new file mode 100644 index 00000000..cfff4ec2 --- /dev/null +++ b/test_samples/extensions/slack-agent/README.md @@ -0,0 +1,25 @@ +# Slack Agent Sample + +Demonstrates the `microsoft-agents-hosting-slack` extension: routing Slack messages and events through an `AgentApplication`, replying via the Slack Web API directly (`chat.postMessage`), posting Block Kit interactive buttons, and using `SlackStream` for incremental message updates. + +## Run + +From this directory: + +```bash +python -m src.main +``` + +## Behavior + +| Slack message | Response | +|---|---| +| `-stream` | Streams progress chunks + a final feedback-buttons block via `chat.startStream` / `chat.appendStream` / `chat.stopStream`. | +| `-buttons` | Posts a Block Kit message with **Yes** / **No** buttons via `chat.postMessage`. | +| anything else | Echoes `"You said: {text}"` via `chat.postMessage` instead of the usual Bot Service activity reply. | +| any Slack event | Replies `"Agent got: {event name}"`. | +| `conversationUpdate` with members added | Sends "Hello and Welcome!". | + +## Configuration + +Copy `env.TEMPLATE` to `.env` and fill in the Azure Bot Service service-connection credentials. Slack must be configured as a channel on the Azure Bot Service resource; the `ApiToken` Slack delivers in `channelData` is the token used to authenticate the outbound Slack Web API calls. diff --git a/test_samples/extensions/slack-agent/env.TEMPLATE b/test_samples/extensions/slack-agent/env.TEMPLATE new file mode 100644 index 00000000..7f771e57 --- /dev/null +++ b/test_samples/extensions/slack-agent/env.TEMPLATE @@ -0,0 +1,3 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= diff --git a/test_samples/extensions/slack-agent/requirements.txt b/test_samples/extensions/slack-agent/requirements.txt new file mode 100644 index 00000000..cc8caff0 --- /dev/null +++ b/test_samples/extensions/slack-agent/requirements.txt @@ -0,0 +1,2 @@ +microsoft-agents-hosting-slack +python-dotenv diff --git a/test_samples/extensions/slack-agent/src/__init__.py b/test_samples/extensions/slack-agent/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test_samples/extensions/slack-agent/src/agent.py b/test_samples/extensions/slack-agent/src/agent.py new file mode 100644 index 00000000..4fd556b7 --- /dev/null +++ b/test_samples/extensions/slack-agent/src/agent.py @@ -0,0 +1,162 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +""" +Slack sample agent. Ports the SlackAgent C# sample to Python. + +- ``-stream`` triggers a streaming response built with ``SlackStream``. +- ``-buttons`` posts a Block Kit message with two interactive buttons. +- Any other Slack message echoes back via ``chat.postMessage`` (so we exercise + the direct-to-Slack API path rather than ``context.send_activity``). +- All Slack events get a ``"Agent got: {name}"`` reply. +- ``conversationUpdate`` with members added sends a welcome via the standard + activity pipeline. +""" + +from __future__ import annotations + +import asyncio + +from microsoft_agents.activity import ConversationUpdateTypes +from microsoft_agents.hosting.core import RouteRank, TurnContext, TurnState +from microsoft_agents.hosting.slack import SlackAgentExtension +from microsoft_agents.hosting.slack.api import ( + MarkdownTextChunk, + SlackChannelData, + SlackTaskStatus, + TaskUpdateChunk, +) + +from .app import APP + +slack = SlackAgentExtension[TurnState](APP) + + +@APP.conversation_update(ConversationUpdateTypes.MEMBERS_ADDED) +async def welcome_added_members(context: TurnContext, state: TurnState): + for member in context.activity.members_added or []: + if member.id != context.activity.recipient.id: + await context.send_activity("Hello and Welcome!") + + +@slack.on_message("-stream") +async def on_slack_stream_message(context: TurnContext, state: TurnState): + stream = await slack.create_stream(context) + try: + await stream.append( + TaskUpdateChunk( + id="task1", title="Working it", status=SlackTaskStatus.IN_PROGRESS + ) + ) + await asyncio.sleep(2) + + await stream.append("This ") + await asyncio.sleep(1.5) + + await stream.append( + [ + MarkdownTextChunk(text="is "), + TaskUpdateChunk( + id="task1", + title="Still working it", + status=SlackTaskStatus.IN_PROGRESS, + ), + ] + ) + await asyncio.sleep(1.5) + + await stream.append("a ") + await asyncio.sleep(1.5) + + await stream.append("test.") + await stream.append( + TaskUpdateChunk(id="task1", title="Done", status=SlackTaskStatus.COMPLETE) + ) + except Exception: + await stream.append( + TaskUpdateChunk(id="task1", title="Error", status=SlackTaskStatus.ERROR) + ) + finally: + # Block Kit feedback buttons: + # https://docs.slack.dev/reference/block-kit/blocks/context-actions-block + feedback_blocks = [ + { + "type": "context_actions", + "elements": [ + { + "type": "feedback_buttons", + "action_id": "feedback", + "positive_button": { + "text": {"type": "plain_text", "text": "👍"}, + "value": "positive_feedback", + }, + "negative_button": { + "text": {"type": "plain_text", "text": "👎"}, + "value": "negative_feedback", + }, + } + ], + } + ] + await stream.stop(blocks=feedback_blocks) + + +@slack.on_message("-buttons") +async def on_slack_buttons(context: TurnContext, state: TurnState): + channel_data = SlackChannelData.from_activity(context.activity) + payload = { + "channel": channel_data.channel, + "thread_ts": channel_data.thread_ts, + "blocks": [ + { + "type": "section", + "text": {"type": "mrkdwn", "text": "Pick an option:"}, + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": {"type": "plain_text", "text": "Yes"}, + "action_id": "button_yes", + "value": "yes", + }, + { + "type": "button", + "text": {"type": "plain_text", "text": "No"}, + "action_id": "button_no", + "value": "no", + }, + ], + }, + ], + } + await slack.call(context, "chat.postMessage", payload, token=channel_data.api_token) + + +@slack.on_message(rank=RouteRank.LAST) +async def on_slack_message(context: TurnContext, state: TurnState): + channel_data = SlackChannelData.from_activity(context.activity) + await slack.call( + context, + "chat.postMessage", + { + "channel": channel_data.channel, + "text": f"You said: {context.activity.text}", + }, + token=channel_data.api_token, + ) + + +@slack.on_event(rank=RouteRank.LAST) +async def on_slack_event(context: TurnContext, state: TurnState): + channel_data = SlackChannelData.from_activity(context.activity) + await slack.call( + context, + "chat.postMessage", + { + "channel": channel_data.channel, + "text": f"Agent got: {context.activity.name}", + }, + token=channel_data.api_token, + ) diff --git a/test_samples/extensions/slack-agent/src/app.py b/test_samples/extensions/slack-agent/src/app.py new file mode 100644 index 00000000..88449a43 --- /dev/null +++ b/test_samples/extensions/slack-agent/src/app.py @@ -0,0 +1,30 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os + +from dotenv import load_dotenv + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + MemoryStorage, + TurnState, +) + +load_dotenv() +agents_sdk_config = load_configuration_from_env(os.environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) +APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) diff --git a/test_samples/extensions/slack-agent/src/main.py b/test_samples/extensions/slack-agent/src/main.py new file mode 100644 index 00000000..5cfc2c0f --- /dev/null +++ b/test_samples/extensions/slack-agent/src/main.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import logging + +ms_agents_logger = logging.getLogger("microsoft_agents") +ms_agents_logger.addHandler(logging.StreamHandler()) +ms_agents_logger.setLevel(logging.INFO) + +from .agent import APP # noqa: E402 (side-effect imports register routes) +from .app import CONNECTION_MANAGER # noqa: E402 +from .start_server import start_server # noqa: E402 + +start_server( + agent_application=APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), +) diff --git a/test_samples/extensions/slack-agent/src/start_server.py b/test_samples/extensions/slack-agent/src/start_server.py new file mode 100644 index 00000000..00aa4516 --- /dev/null +++ b/test_samples/extensions/slack-agent/src/start_server.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + +from aiohttp.web import Application, Request, Response, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication, AgentAuthConfiguration + + +def start_server( + agent_application: AgentApplication, + auth_configuration: AgentAuthConfiguration, +) -> None: + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + app = Application(middlewares=[jwt_authorization_middleware]) + app.router.add_post("/api/messages", entry_point) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + + run_app(app, host="localhost", port=environ.get("PORT", 3978)) diff --git a/tests/hosting_slack/__init__.py b/tests/hosting_slack/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_slack/test_path_navigator.py b/tests/hosting_slack/test_path_navigator.py new file mode 100644 index 00000000..f7bf7b80 --- /dev/null +++ b/tests/hosting_slack/test_path_navigator.py @@ -0,0 +1,56 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from microsoft_agents.hosting.slack._path_navigator import try_get_path_value + + +class TestPathNavigator: + def test_empty_path_returns_root(self): + data = {"a": 1} + found, value = try_get_path_value(data, "") + assert found and value is data + + def test_simple_dot_path(self): + found, value = try_get_path_value({"a": {"b": "c"}}, "a.b") + assert found and value == "c" + + def test_array_index(self): + found, value = try_get_path_value({"a": [10, 20, 30]}, "a[1]") + assert found and value == 20 + + def test_negative_array_index(self): + found, value = try_get_path_value({"a": [10, 20, 30]}, "a[-1]") + assert found and value == 30 + + def test_nested_array_and_object(self): + found, value = try_get_path_value( + {"a": [{"b": [{"c": "deep"}]}]}, "a[0].b[0].c" + ) + assert found and value == "deep" + + def test_missing_key_returns_not_found(self): + found, value = try_get_path_value({"a": 1}, "b") + assert not found and value is None + + def test_out_of_range_index(self): + found, _ = try_get_path_value({"a": [1, 2]}, "a[5]") + assert not found + + def test_case_insensitive_fallback(self): + found, value = try_get_path_value({"FooBar": 7}, "foobar") + assert found and value == 7 + + def test_case_sensitive_takes_precedence(self): + # Exact match wins even when a case-insensitive sibling exists + found, value = try_get_path_value({"foo": 1, "FOO": 2}, "foo") + assert found and value == 1 + + def test_unbalanced_brackets_return_not_found(self): + found, _ = try_get_path_value({"a": [1]}, "a[0") + assert not found + + def test_primitive_node_property_access_returns_not_found(self): + found, _ = try_get_path_value({"a": "string"}, "a.length") + assert not found diff --git a/tests/hosting_slack/test_slack_agent_extension.py b/tests/hosting_slack/test_slack_agent_extension.py new file mode 100644 index 00000000..20941277 --- /dev/null +++ b/tests/hosting_slack/test_slack_agent_extension.py @@ -0,0 +1,166 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import re +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from microsoft_agents.activity import Activity, ActivityTypes +from microsoft_agents.hosting.core import TurnContext +from microsoft_agents.hosting.core.app import AgentApplication, RouteRank +from microsoft_agents.hosting.slack import SlackAgentExtension + + +def _make_app() -> MagicMock: + app = MagicMock(spec=AgentApplication) + app.routes = [] + + def _add_route( + selector, handler, is_invoke=False, rank=RouteRank.DEFAULT, auth_handlers=None + ): + app.routes.append( + dict( + selector=selector, + handler=handler, + is_invoke=is_invoke, + rank=rank, + auth_handlers=auth_handlers, + ) + ) + + app.add_route.side_effect = _add_route + return app + + +def _make_context( + activity_type: str, + *, + channel_id: str = "slack", + text: str = None, + name: str = None, +) -> TurnContext: + activity = MagicMock(spec=Activity) + activity.type = activity_type + activity.channel_id = channel_id + activity.text = text + activity.name = name + context = MagicMock(spec=TurnContext) + context.activity = activity + context.send_activity = AsyncMock() + context.has.return_value = False + return context + + +class TestOnMessage: + def setup_method(self): + self.app = _make_app() + self.slack = SlackAgentExtension(self.app) + + def test_no_text_matches_any_slack_message(self): + @self.slack.on_message() + async def handler(context, state): + return True + + route = self.app.routes[-1] + # bare on_message should default to RouteRank.LAST + assert route["rank"] == RouteRank.LAST + # matches any Slack message + assert route["selector"](_make_context(ActivityTypes.message, text="anything")) + # does NOT match non-slack channels + assert not route["selector"]( + _make_context(ActivityTypes.message, channel_id="msteams", text="x") + ) + # does NOT match non-message activities + assert not route["selector"](_make_context(ActivityTypes.event, text="x")) + + def test_literal_text_match(self): + @self.slack.on_message("hello") + async def handler(context, state): + return True + + sel = self.app.routes[-1]["selector"] + assert sel(_make_context(ActivityTypes.message, text="hello")) + assert not sel(_make_context(ActivityTypes.message, text="bye")) + + def test_regex_text_match(self): + @self.slack.on_message(re.compile(r"^-stream\b.*")) + async def handler(context, state): + return True + + sel = self.app.routes[-1]["selector"] + assert sel(_make_context(ActivityTypes.message, text="-stream now")) + assert not sel(_make_context(ActivityTypes.message, text="stream now")) + + def test_custom_rank_preserved(self): + @self.slack.on_message("hi", rank=RouteRank.FIRST) + async def handler(context, state): + return True + + assert self.app.routes[-1]["rank"] == RouteRank.FIRST + + +class TestOnEvent: + def setup_method(self): + self.app = _make_app() + self.slack = SlackAgentExtension(self.app) + + def test_no_name_matches_any_slack_event(self): + @self.slack.on_event() + async def handler(context, state): + return True + + route = self.app.routes[-1] + assert route["rank"] == RouteRank.LAST + assert route["selector"](_make_context(ActivityTypes.event, name="any")) + assert not route["selector"]( + _make_context(ActivityTypes.event, channel_id="msteams", name="any") + ) + assert not route["selector"](_make_context(ActivityTypes.message)) + + def test_literal_event_name(self): + @self.slack.on_event("app_mention") + async def handler(context, state): + return True + + sel = self.app.routes[-1]["selector"] + assert sel(_make_context(ActivityTypes.event, name="app_mention")) + assert not sel(_make_context(ActivityTypes.event, name="other")) + + +class TestCall: + @pytest.mark.asyncio + async def test_call_delegates_to_slack_api(self): + app = _make_app() + slack_api = MagicMock() + slack_api.call = AsyncMock(return_value="result") + slack = SlackAgentExtension(app, slack_api=slack_api) + + ctx = _make_context(ActivityTypes.message) + out = await slack.call(ctx, "chat.postMessage", {"k": "v"}, token="t") + + assert out == "result" + slack_api.call.assert_awaited_once_with("chat.postMessage", {"k": "v"}, "t") + + @pytest.mark.asyncio + async def test_call_prefers_turn_context_service_when_present(self): + app = _make_app() + default_api = MagicMock() + default_api.call = AsyncMock(return_value="default") + per_turn_api = MagicMock() + per_turn_api.call = AsyncMock(return_value="per-turn") + + slack = SlackAgentExtension(app, slack_api=default_api) + + ctx = _make_context(ActivityTypes.message) + ctx.has.return_value = True + ctx.get.return_value = per_turn_api + + out = await slack.call(ctx, "auth.test") + assert out == "per-turn" + per_turn_api.call.assert_awaited_once() + default_api.call.assert_not_awaited() diff --git a/tests/hosting_slack/test_slack_api.py b/tests/hosting_slack/test_slack_api.py new file mode 100644 index 00000000..efa4e6cb --- /dev/null +++ b/tests/hosting_slack/test_slack_api.py @@ -0,0 +1,136 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import json +from contextlib import asynccontextmanager +from typing import Any + +import pytest + +from microsoft_agents.hosting.slack.api import ( + SlackApi, + SlackResponseException, +) + + +class _FakeResponse: + def __init__(self, status: int, body: str) -> None: + self.status = status + self.ok = 200 <= status < 300 + self._body = body + + async def text(self) -> str: + return self._body + + async def __aenter__(self) -> "_FakeResponse": + return self + + async def __aexit__(self, *_args: Any) -> None: + return None + + +class _FakeSession: + """Records each POST call and returns a pre-canned response.""" + + def __init__(self, response: _FakeResponse) -> None: + self._response = response + self.calls: list[dict[str, Any]] = [] + + def post(self, url: str, *, data: str, headers: dict, timeout=None): + self.calls.append( + {"url": url, "data": data, "headers": dict(headers), "timeout": timeout} + ) + return self._response + + async def close(self) -> None: # honoured if owned + return None + + +@pytest.mark.asyncio +async def test_call_serializes_dict_and_sends_bearer_token(): + session = _FakeSession(_FakeResponse(200, '{"ok": true, "ts": "123.456"}')) + api = SlackApi(session=session) + + result = await api.call( + "chat.postMessage", + {"channel": "C1", "text": "hi", "drop_me": None}, + token="xoxb-abc", + ) + + assert result.ok is True + assert result.ts == "123.456" + assert len(session.calls) == 1 + call = session.calls[0] + assert call["url"] == "https://slack.com/api/chat.postMessage" + assert call["headers"]["Authorization"] == "Bearer xoxb-abc" + body = json.loads(call["data"]) + # None values are stripped before serialization + assert body == {"channel": "C1", "text": "hi"} + + +@pytest.mark.asyncio +async def test_call_uses_string_body_verbatim(): + session = _FakeSession(_FakeResponse(200, '{"ok": true}')) + api = SlackApi(session=session) + raw = '{"channel":"C1","text":"raw"}' + + await api.call("chat.postMessage", raw, token="t") + + assert session.calls[0]["data"] == raw + + +@pytest.mark.asyncio +async def test_call_without_token_omits_authorization_header(): + session = _FakeSession(_FakeResponse(200, '{"ok": true}')) + api = SlackApi(session=session) + await api.call("auth.test") + assert "Authorization" not in session.calls[0]["headers"] + + +@pytest.mark.asyncio +async def test_call_raises_on_non_ok_response(): + session = _FakeSession( + _FakeResponse(200, '{"ok": false, "error": "channel_not_found"}') + ) + api = SlackApi(session=session) + + with pytest.raises(SlackResponseException) as exc_info: + await api.call("chat.postMessage", {"channel": "X"}, token="t") + assert "channel_not_found" in str(exc_info.value) or "ok" in str(exc_info.value) + # The exception carries the parsed response + assert exc_info.value.response is not None + assert exc_info.value.response.error == "channel_not_found" + + +@pytest.mark.asyncio +async def test_call_raises_on_http_error_with_unparseable_body(): + session = _FakeSession(_FakeResponse(500, "oops")) + api = SlackApi(session=session) + with pytest.raises(SlackResponseException): + await api.call("chat.postMessage", {}, token="t") + + +@pytest.mark.asyncio +async def test_call_rejects_blank_method(): + api = SlackApi(session=_FakeSession(_FakeResponse(200, '{"ok": true}'))) + with pytest.raises(ValueError): + await api.call(" ") + + +@pytest.mark.asyncio +async def test_pydantic_model_options_are_dumped_without_nulls(): + from pydantic import BaseModel + + class Opts(BaseModel): + channel: str + text: str | None = None + + session = _FakeSession(_FakeResponse(200, '{"ok": true}')) + api = SlackApi(session=session) + await api.call("chat.postMessage", Opts(channel="C1"), token="t") + body = json.loads(session.calls[0]["data"]) + assert body == {"channel": "C1"} diff --git a/tests/hosting_slack/test_slack_channel_data.py b/tests/hosting_slack/test_slack_channel_data.py new file mode 100644 index 00000000..542fcbd4 --- /dev/null +++ b/tests/hosting_slack/test_slack_channel_data.py @@ -0,0 +1,271 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Tests for SlackChannelData / EventEnvelope / EventContent deserialization and +path-navigation helpers. JSON fixtures mirror the real Slack Events API +examples from https://docs.slack.dev/apis/events-api/#callback-field +""" + +import json + +import pytest + +from microsoft_agents.hosting.slack.api import ( + EventContent, + EventEnvelope, + SlackChannelData, +) + +MESSAGE_EVENT_JSON = """ +{ + "SlackMessage": { + "token": "7K85CE7U1wjgFDUHafCiPB7l", + "team_id": "T0AT0TZM9GD", + "context_team_id": "T0AT0TZM9GD", + "context_enterprise_id": null, + "api_app_id": "A0AT4GSCQHG", + "event": { + "type": "message", + "user": "U0ASNSMMY07", + "ts": "1776271070.726439", + "client_msg_id": "c9c2aa5e-03fd-48d6-8665-6680d91c8541", + "text": "hi", + "team": "T0AT0TZM9GD", + "blocks": [ + { + "type": "rich_text", + "block_id": "a8bcU", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { "type": "text", "text": "hi" } + ] + } + ] + } + ], + "channel": "D0AT8AL9LA0", + "event_ts": "1776271070.726439", + "channel_type": "im" + }, + "type": "event_callback", + "event_id": "Ev0AT2MA48S2", + "event_time": 1776271070, + "authorizations": [ + { + "enterprise_id": null, + "team_id": "T0AT0TZM9GD", + "user_id": "U0AT1AL4C5T", + "is_bot": true, + "is_enterprise_install": false + } + ], + "is_ext_shared_channel": false, + "event_context": "4-abc" + }, + "ApiToken": "xoxb-test-message-token" +} +""" + +REACTION_REMOVED_JSON = """ +{ + "SlackMessage": { + "token": "tok", + "team_id": "T0AT0TZM9GD", + "api_app_id": "A0AT4GSCQHG", + "event": { + "type": "reaction_removed", + "user": "U0ASNSMMY07", + "reaction": "raised_hands", + "item": { + "type": "message", + "channel": "D0AT8AL9LA0", + "ts": "1776373312.421509" + }, + "item_user": "U0AT1AL4C5T", + "event_ts": "1776373798.000200" + }, + "type": "event_callback", + "event_id": "Ev0AUASNK732", + "event_time": 1776373798, + "is_ext_shared_channel": false + }, + "ApiToken": "xoxb-test-reaction-token" +} +""" + + +def _channel_data(json_str: str) -> SlackChannelData: + return SlackChannelData.model_validate(json.loads(json_str)) + + +class TestSlackChannelDataDeserialize: + def test_envelope_present(self): + cd = _channel_data(MESSAGE_EVENT_JSON) + assert cd.envelope is not None + + def test_api_token_preserved(self): + cd = _channel_data(MESSAGE_EVENT_JSON) + assert cd.api_token == "xoxb-test-message-token" + + def test_channel_shortcut(self): + cd = _channel_data(MESSAGE_EVENT_JSON) + assert cd.channel == "D0AT8AL9LA0" + + def test_thread_ts_falls_back_to_ts(self): + cd = _channel_data(MESSAGE_EVENT_JSON) + # event has no thread_ts → fallback to event.ts + assert cd.thread_ts == "1776271070.726439" + + def test_additional_top_level_field_preserved(self): + raw = { + "SlackMessage": {"type": "event_callback", "event": {"type": "message"}}, + "ApiToken": "tok", + "custom_field": "hello", + } + cd = SlackChannelData.model_validate(raw) + dumped = cd.model_dump(mode="json", by_alias=True) + assert dumped.get("custom_field") == "hello" + + +class TestEventEnvelope: + def test_top_level_fields(self): + envelope = _channel_data(MESSAGE_EVENT_JSON).envelope + assert envelope.token == "7K85CE7U1wjgFDUHafCiPB7l" + assert envelope.team_id == "T0AT0TZM9GD" + assert envelope.context_enterprise_id is None + assert envelope.api_app_id == "A0AT4GSCQHG" + assert envelope.type == "event_callback" + assert envelope.event_id == "Ev0AT2MA48S2" + assert envelope.event_time == 1776271070 + assert envelope.is_ext_shared_channel is False + + def test_event_content_present(self): + envelope = _channel_data(MESSAGE_EVENT_JSON).envelope + assert envelope.event_content is not None + assert isinstance(envelope.event_content, EventContent) + + def test_get_authorizations_returns_list(self): + envelope = _channel_data(MESSAGE_EVENT_JSON).envelope + auths = envelope.get("authorizations") + assert isinstance(auths, list) + assert len(auths) == 1 + assert auths[0]["team_id"] == "T0AT0TZM9GD" + assert auths[0]["is_bot"] is True + + def test_event_content_alias_prefix_is_supported(self): + envelope = _channel_data(MESSAGE_EVENT_JSON).envelope + # `event_content.` should be normalized to `event.` + assert envelope.get("event_content.channel") == "D0AT8AL9LA0" + assert envelope.get("event.channel") == "D0AT8AL9LA0" + assert envelope.get("event_content") == envelope.get("event") + + +class TestEventContentNamedProperties: + def test_message_event_named_properties(self): + content = _channel_data(MESSAGE_EVENT_JSON).envelope.event_content + assert content.type == "message" + assert content.user == "U0ASNSMMY07" + assert content.ts == "1776271070.726439" + assert content.event_ts == "1776271070.726439" + assert content.client_msg_id == "c9c2aa5e-03fd-48d6-8665-6680d91c8541" + assert content.text == "hi" + assert content.team == "T0AT0TZM9GD" + assert content.channel == "D0AT8AL9LA0" + assert content.channel_type == "im" + assert content.subtype is None + assert content.reaction is None + assert content.item_user is None + + def test_reaction_removed_named_properties(self): + content = _channel_data(REACTION_REMOVED_JSON).envelope.event_content + assert content.type == "reaction_removed" + assert content.user == "U0ASNSMMY07" + assert content.reaction == "raised_hands" + assert content.item_user == "U0AT1AL4C5T" + assert content.event_ts == "1776373798.000200" + assert content.channel is None + assert content.text is None + + +class TestEventContentPathNavigation: + def test_simple_property(self): + content = _channel_data(MESSAGE_EVENT_JSON).envelope.event_content + assert content.get("type") == "message" + assert content.get("text") == "hi" + assert content.get("channel") == "D0AT8AL9LA0" + + def test_missing_path_returns_default(self): + content = _channel_data(MESSAGE_EVENT_JSON).envelope.event_content + assert content.get("nonexistent_field") is None + assert content.get("nonexistent_field", default="fallback") == "fallback" + + def test_nested_object_path(self): + content = _channel_data(REACTION_REMOVED_JSON).envelope.event_content + assert content.get("item.type") == "message" + assert content.get("item.channel") == "D0AT8AL9LA0" + assert content.get("item.ts") == "1776373312.421509" + + def test_array_indexing(self): + content = _channel_data(MESSAGE_EVENT_JSON).envelope.event_content + assert content.get("blocks[0].type") == "rich_text" + assert content.get("blocks[0].block_id") == "a8bcU" + assert content.get("blocks[0].elements[0].type") == "rich_text_section" + assert content.get("blocks[0].elements[0].elements[0].text") == "hi" + + def test_try_get_found_and_missing(self): + content = _channel_data(MESSAGE_EVENT_JSON).envelope.event_content + found, value = content.try_get("text") + assert found is True and value == "hi" + + found, value = content.try_get("nope") + assert found is False and value is None + + +class TestActionPayloadChannelData: + def test_thread_ts_from_payload_message(self): + raw = { + "Payload": { + "type": "block_actions", + "channel": {"id": "C123"}, + "message": {"ts": "111.222"}, + "actions": [], + }, + "ApiToken": "xoxb-x", + } + cd = SlackChannelData.model_validate(raw) + assert cd.payload is not None + # payload.channel is itself an object in this fixture + assert cd.channel == {"id": "C123"} + # path-based access drills into it + assert cd.payload.get("channel.id") == "C123" + assert cd.thread_ts == "111.222" + + +class TestFromActivity: + def test_from_activity_with_dict_channel_data(self): + class FakeActivity: + channel_data = json.loads(MESSAGE_EVENT_JSON) + + cd = SlackChannelData.from_activity(FakeActivity()) + assert cd.api_token == "xoxb-test-message-token" + assert cd.channel == "D0AT8AL9LA0" + + def test_from_activity_none_channel_data(self): + class FakeActivity: + channel_data = None + + cd = SlackChannelData.from_activity(FakeActivity()) + assert cd.envelope is None + assert cd.api_token is None + + def test_from_activity_passes_through_instance(self): + existing = SlackChannelData(api_token="abc") + + class FakeActivity: + channel_data = existing + + cd = SlackChannelData.from_activity(FakeActivity()) + assert cd is existing diff --git a/tests/hosting_slack/test_slack_helpers.py b/tests/hosting_slack/test_slack_helpers.py new file mode 100644 index 00000000..4d0e1f26 --- /dev/null +++ b/tests/hosting_slack/test_slack_helpers.py @@ -0,0 +1,50 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest + +from microsoft_agents.hosting.slack import ( + create_conversation_id, + slack_bot_id_from_conversation_id, + slack_channel_id_from_conversation_id, + slack_decode, + slack_encode, + slack_team_id_from_conversation_id, + slack_thread_ts_from_conversation_id, +) + + +class TestEncodeDecode: + def test_encode_handles_special_chars(self): + assert slack_encode("a & b d") == "a & b <c> d" + + def test_decode_round_trip(self): + original = "a & b d" + assert slack_decode(slack_encode(original)) == original + + def test_encode_none(self): + assert slack_encode(None) is None + assert slack_decode(None) is None + + +class TestConversationIdRoundTrip: + def test_with_thread_ts(self): + cid = create_conversation_id("B1", "T1", "C1", "111.222") + assert cid == "B1:T1:C1:111.222" + assert slack_bot_id_from_conversation_id(cid) == "B1" + assert slack_team_id_from_conversation_id(cid) == "T1" + assert slack_channel_id_from_conversation_id(cid) == "C1" + assert slack_thread_ts_from_conversation_id(cid) == "111.222" + + def test_without_thread_ts(self): + cid = create_conversation_id("B1", "T1", "C1") + assert cid == "B1:T1:C1" + assert slack_thread_ts_from_conversation_id(cid) is None + + def test_invalid_id_raises(self): + with pytest.raises(ValueError): + slack_bot_id_from_conversation_id("only:two") + with pytest.raises(ValueError): + slack_bot_id_from_conversation_id("") diff --git a/tests/hosting_slack/test_slack_response.py b/tests/hosting_slack/test_slack_response.py new file mode 100644 index 00000000..2f081552 --- /dev/null +++ b/tests/hosting_slack/test_slack_response.py @@ -0,0 +1,46 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from microsoft_agents.hosting.slack.api import SlackResponse + + +class TestSlackResponse: + def test_minimal_ok_true(self): + r = SlackResponse.model_validate({"ok": True}) + assert r.ok is True + assert r.error is None + + def test_ok_false_with_error(self): + r = SlackResponse.model_validate({"ok": False, "error": "not_authed"}) + assert r.ok is False + assert r.error == "not_authed" + + def test_extra_fields_preserved_via_get(self): + r = SlackResponse.model_validate( + { + "ok": True, + "ts": "1776.001", + "channel": "C0001", + "message": { + "ts": "1776.001", + "text": "hi", + "attachments": [{"text": "alt"}], + }, + } + ) + assert r.ts == "1776.001" + assert r.get("channel") == "C0001" + assert r.get("message.text") == "hi" + assert r.get("message.attachments[0].text") == "alt" + # missing path returns None / default + assert r.get("nope") is None + assert r.get("nope", default="x") == "x" + + def test_try_get(self): + r = SlackResponse.model_validate({"ok": True, "warning": "missing_charset"}) + found, value = r.try_get("warning") + assert found and value == "missing_charset" + found, _ = r.try_get("nope") + assert not found diff --git a/tests/hosting_slack/test_slack_stream.py b/tests/hosting_slack/test_slack_stream.py new file mode 100644 index 00000000..67871338 --- /dev/null +++ b/tests/hosting_slack/test_slack_stream.py @@ -0,0 +1,146 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from microsoft_agents.hosting.slack.api import ( + BlocksChunk, + MarkdownTextChunk, + SlackResponse, + SlackStream, + SlackTaskStatus, + TaskUpdateChunk, +) + + +def _fake_api(start_ts: str = "1.0"): + api = MagicMock() + api.call = AsyncMock(return_value=SlackResponse(ok=True, ts=start_ts)) + return api + + +@pytest.mark.asyncio +async def test_start_records_ts_and_calls_chat_startStream(): + api = _fake_api(start_ts="100.0") + stream = SlackStream(api, "C1", "thread1", "tok") + await stream.start() + method, body, token = api.call.call_args.args + assert method == "chat.startStream" + assert token == "tok" + assert body == { + "channel": "C1", + "thread_ts": "thread1", + "task_display_mode": "plan", + } + + +@pytest.mark.asyncio +async def test_append_string_wraps_in_markdown_chunk(): + api = _fake_api() + stream = SlackStream(api, "C1", "thread1", "tok") + await stream.start() + api.call.reset_mock() + + await stream.append("hello world") + method, body, _ = api.call.call_args.args + assert method == "chat.appendStream" + assert body["chunks"] == [{"type": "markdown_text", "text": "hello world"}] + + +@pytest.mark.asyncio +async def test_append_chunk_list(): + api = _fake_api() + stream = SlackStream(api, "C1", "thread1", "tok") + await stream.start() + api.call.reset_mock() + + await stream.append( + [ + MarkdownTextChunk(text="hi "), + TaskUpdateChunk( + id="t1", title="Doing it", status=SlackTaskStatus.IN_PROGRESS + ), + ] + ) + body = api.call.call_args.args[1] + assert body["chunks"][0]["type"] == "markdown_text" + assert body["chunks"][1]["type"] == "task_update" + assert body["chunks"][1]["id"] == "t1" + assert body["chunks"][1]["status"] == "in_progress" + + +@pytest.mark.asyncio +async def test_append_empty_list_no_call(): + api = _fake_api() + stream = SlackStream(api, "C1", "thread1", "tok") + await stream.start() + api.call.reset_mock() + + await stream.append([]) + api.call.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_stop_with_blocks_string_object_extracts_array(): + api = _fake_api() + stream = SlackStream(api, "C1", "thread1", "tok") + await stream.start() + api.call.reset_mock() + + blocks_json = '{"blocks": [{"type": "section"}]}' + await stream.stop(blocks=blocks_json) + method, body, _ = api.call.call_args.args + assert method == "chat.stopStream" + assert body["blocks"] == [{"type": "section"}] + + +@pytest.mark.asyncio +async def test_stop_with_blocks_array_string_passes_through(): + api = _fake_api() + stream = SlackStream(api, "C1", "thread1", "tok") + await stream.start() + api.call.reset_mock() + + await stream.stop(blocks='[{"type": "section"}]') + body = api.call.call_args.args[1] + assert body["blocks"] == [{"type": "section"}] + + +@pytest.mark.asyncio +async def test_stop_with_object_missing_blocks_array_raises(): + api = _fake_api() + stream = SlackStream(api, "C1", "thread1", "tok") + await stream.start() + + with pytest.raises(ValueError): + await stream.stop(blocks='{"foo": "bar"}') + + +@pytest.mark.asyncio +async def test_stop_before_start_is_noop(): + api = _fake_api() + stream = SlackStream(api, "C1", "thread1", "tok") + await stream.stop() + api.call.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_stop_with_python_blocks_list(): + api = _fake_api() + stream = SlackStream(api, "C1", "thread1", "tok") + await stream.start() + api.call.reset_mock() + + await stream.stop( + chunks=[MarkdownTextChunk(text="done")], + blocks=[{"type": "section", "text": {"type": "mrkdwn", "text": "ok"}}], + ) + body = api.call.call_args.args[1] + assert body["blocks"][0]["type"] == "section" + assert body["chunks"][0]["text"] == "done"