microsoft-agents-hosting-slack 1.1.0.dev8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from .slack_agent_extension import SlackAgentExtension
7
+ from .slack_helpers import (
8
+ create_conversation_id,
9
+ slack_bot_id_from_conversation_id,
10
+ slack_channel_id_from_conversation_id,
11
+ slack_decode,
12
+ slack_encode,
13
+ slack_team_id_from_conversation_id,
14
+ slack_thread_ts_from_conversation_id,
15
+ )
16
+
17
+ __all__ = [
18
+ "SlackAgentExtension",
19
+ "create_conversation_id",
20
+ "slack_bot_id_from_conversation_id",
21
+ "slack_channel_id_from_conversation_id",
22
+ "slack_decode",
23
+ "slack_encode",
24
+ "slack_team_id_from_conversation_id",
25
+ "slack_thread_ts_from_conversation_id",
26
+ ]
@@ -0,0 +1,108 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional, Tuple
9
+
10
+
11
+ def _parse_path(path: str) -> Optional[list[int | str]]:
12
+ """Tokenize a dot/bracket path into a list of segments.
13
+
14
+ Supports ``a.b.c``, ``a[0].b``, ``a[1][2]``. Integer brackets become ``int``
15
+ segments; everything else becomes a string segment. Returns ``None`` for
16
+ malformed bracket nesting.
17
+ """
18
+ if not path:
19
+ return []
20
+
21
+ segments: list[Any] = []
22
+ i = 0
23
+ start = 0
24
+
25
+ def emit() -> None:
26
+ nonlocal start
27
+ if start < i:
28
+ segments.append(path[start:i])
29
+ start = i + 1
30
+
31
+ while i < len(path):
32
+ ch = path[i]
33
+ if ch == ".":
34
+ emit()
35
+ elif ch == "[":
36
+ emit()
37
+ nesting = 1
38
+ i += 1
39
+ inner_start = i
40
+ while i < len(path):
41
+ c = path[i]
42
+ if c == "[":
43
+ nesting += 1
44
+ elif c == "]":
45
+ nesting -= 1
46
+ if nesting == 0:
47
+ break
48
+ i += 1
49
+ if nesting != 0:
50
+ return None
51
+ inner = path[inner_start:i]
52
+ if inner.isdigit() or (inner.startswith("-") and inner[1:].isdigit()):
53
+ segments.append(int(inner))
54
+ else:
55
+ segments.append(inner)
56
+ start = i + 1
57
+ i += 1
58
+
59
+ emit()
60
+ return segments
61
+
62
+
63
+ def _resolve_segment(current: Any, segment: Any) -> Tuple[bool, Any]:
64
+ """Resolve one path segment against the current node.
65
+
66
+ Returns ``(found, value)``. ``found`` is False when the segment cannot be
67
+ resolved (missing key, out-of-range index, primitive node).
68
+ """
69
+ if current is None:
70
+ return False, None
71
+
72
+ if isinstance(segment, int):
73
+ if isinstance(current, (list, tuple)):
74
+ if -len(current) <= segment < len(current):
75
+ return True, current[segment]
76
+ return False, None
77
+ return False, None
78
+
79
+ # string segment → dict key (case-sensitive fast path, case-insensitive fallback)
80
+ if isinstance(current, dict):
81
+ if segment in current:
82
+ return True, current[segment]
83
+ lower = segment.lower()
84
+ for key, value in current.items():
85
+ if isinstance(key, str) and key.lower() == lower:
86
+ return True, value
87
+ return False, None
88
+
89
+ return False, None
90
+
91
+
92
+ def try_get_path_value(data: Any, path: str) -> Tuple[bool, Any]:
93
+ """Walk ``path`` against ``data``. Returns ``(found, value)``."""
94
+ if data is None:
95
+ return False, None
96
+ if not path:
97
+ return True, data
98
+
99
+ segments = _parse_path(path)
100
+ if segments is None:
101
+ return False, None
102
+
103
+ current: Any = data
104
+ for segment in segments:
105
+ found, current = _resolve_segment(current, segment)
106
+ if not found:
107
+ return False, None
108
+ return True, current
@@ -0,0 +1,42 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from .action_payload import ActionPayload
7
+ from .chunks import (
8
+ BlocksChunk,
9
+ Chunk,
10
+ MarkdownTextChunk,
11
+ SlackTaskStatus,
12
+ Source,
13
+ TaskDisplayMode,
14
+ TaskUpdateChunk,
15
+ )
16
+ from .event_content import EventContent
17
+ from .event_envelope import EventEnvelope
18
+ from .slack_api import SLACK_API_BASE, SlackApi
19
+ from .slack_channel_data import SlackChannelData
20
+ from .slack_model import SlackModel
21
+ from .slack_response import SlackResponse, SlackResponseException
22
+ from .slack_stream import SlackStream
23
+
24
+ __all__ = [
25
+ "ActionPayload",
26
+ "BlocksChunk",
27
+ "Chunk",
28
+ "EventContent",
29
+ "EventEnvelope",
30
+ "MarkdownTextChunk",
31
+ "SLACK_API_BASE",
32
+ "SlackApi",
33
+ "SlackChannelData",
34
+ "SlackModel",
35
+ "SlackResponse",
36
+ "SlackResponseException",
37
+ "SlackStream",
38
+ "SlackTaskStatus",
39
+ "Source",
40
+ "TaskDisplayMode",
41
+ "TaskUpdateChunk",
42
+ ]
@@ -0,0 +1,26 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional
9
+
10
+ from pydantic import ConfigDict
11
+
12
+ from .slack_model import SlackModel
13
+
14
+
15
+ class ActionPayload(SlackModel):
16
+ """
17
+ Interactive Message / Block Kit action payload from Slack. Sent when a user
18
+ clicks a button or interacts with a block element.
19
+ """
20
+
21
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
22
+
23
+ type: Optional[str] = None
24
+ channel: Optional[Any] = None
25
+ message: Optional[Any] = None
26
+ actions: Optional[Any] = None
@@ -0,0 +1,77 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+
5
+ Streaming chunk shapes for Slack ``chat.appendStream`` / ``chat.stopStream``.
6
+ See https://docs.slack.dev/reference/methods/chat.appendStream
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field
14
+
15
+
16
+ class SlackTaskStatus:
17
+ """Status values accepted by :class:`TaskUpdateChunk`."""
18
+
19
+ PENDING = "pending"
20
+ IN_PROGRESS = "in_progress"
21
+ COMPLETE = "complete"
22
+ ERROR = "error"
23
+
24
+
25
+ class TaskDisplayMode:
26
+ """Values accepted by ``chat.startStream``'s ``task_display_mode``."""
27
+
28
+ PLAN = "plan"
29
+ TIMELINE = "timeline"
30
+
31
+
32
+ class Source(BaseModel):
33
+ """Citation/source attached to a :class:`TaskUpdateChunk`."""
34
+
35
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
36
+
37
+ type: str = "url"
38
+ url: str = ""
39
+ text: str = ""
40
+
41
+
42
+ class MarkdownTextChunk(BaseModel):
43
+ """Append a chunk of markdown-formatted text to a Slack stream."""
44
+
45
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
46
+
47
+ type: str = Field(default="markdown_text", frozen=True)
48
+ text: str = ""
49
+
50
+
51
+ class BlocksChunk(BaseModel):
52
+ """Append a chunk of Block Kit blocks to a Slack stream."""
53
+
54
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
55
+
56
+ type: str = Field(default="blocks", frozen=True)
57
+ blocks: list[Any] = Field(default_factory=list)
58
+
59
+
60
+ class TaskUpdateChunk(BaseModel):
61
+ """Append a task-status update chunk to a Slack stream."""
62
+
63
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
64
+
65
+ type: str = Field(default="task_update", frozen=True)
66
+ id: str
67
+ title: str
68
+ status: str = SlackTaskStatus.IN_PROGRESS
69
+ details: Optional[str] = None
70
+ output: Optional[str] = None
71
+ sources: Optional[list[Source]] = None
72
+
73
+
74
+ # Type alias for any chunk variant.
75
+ Chunk = (
76
+ BaseModel # all chunk classes are BaseModel subclasses with a `type` discriminator
77
+ )
@@ -0,0 +1,45 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+ from pydantic import ConfigDict
11
+
12
+ from .slack_model import SlackModel
13
+
14
+
15
+ class EventContent(SlackModel):
16
+ """
17
+ The inner ``event`` object from a Slack Events API callback payload.
18
+
19
+ Slack calls this the "event content". Because event payloads vary so widely
20
+ by ``type``, every unmodelled field is preserved via Pydantic's
21
+ ``extra="allow"`` and is reachable through :meth:`SlackModel.get` using the
22
+ same snake_case names shown in the Slack docs.
23
+
24
+ See https://docs.slack.dev/reference/events
25
+ """
26
+
27
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
28
+
29
+ # ── Common event fields (https://docs.slack.dev/apis/events-api/#event-type-structure)
30
+ type: Optional[str] = None
31
+ event_ts: Optional[str] = None
32
+ user: Optional[str] = None
33
+ ts: Optional[str] = None
34
+ subtype: Optional[str] = None
35
+ channel: Optional[str] = None
36
+ channel_type: Optional[str] = None
37
+ team: Optional[str] = None
38
+
39
+ # ── message event fields ──
40
+ text: Optional[str] = None
41
+ client_msg_id: Optional[str] = None
42
+
43
+ # ── reaction_added / reaction_removed event fields ──
44
+ reaction: Optional[str] = None
45
+ item_user: Optional[str] = None
@@ -0,0 +1,56 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional
9
+
10
+ from pydantic import ConfigDict, Field
11
+
12
+ from .event_content import EventContent
13
+ from .slack_model import SlackModel
14
+
15
+
16
+ class EventEnvelope(SlackModel):
17
+ """
18
+ Outer envelope for a Slack Events API callback. Contains workspace, application,
19
+ and authorization context, plus the inner event payload (``event_content``).
20
+
21
+ See https://docs.slack.dev/apis/events-api/#callback-field.
22
+
23
+ Path navigation supports both the Slack JSON prefix ``event.`` and the Python
24
+ property prefix ``event_content.`` interchangeably::
25
+
26
+ envelope = SlackChannelData.from_activity(turn_context.activity).envelope
27
+ workspace_id = envelope.get("team_id")
28
+ channel = envelope.get("event.channel")
29
+ block_type = envelope.get("event.blocks[0].type")
30
+ """
31
+
32
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
33
+
34
+ token: Optional[str] = None
35
+ team_id: Optional[str] = None
36
+ context_team_id: Optional[str] = None
37
+ context_enterprise_id: Optional[str] = None
38
+ api_app_id: Optional[str] = None
39
+
40
+ # Python name `event_content` ↔ JSON field `event`
41
+ event_content: Optional[EventContent] = Field(default=None, alias="event")
42
+
43
+ type: Optional[str] = None
44
+ event_id: Optional[str] = None
45
+ event_time: Optional[int] = None
46
+ authorizations: Optional[Any] = None
47
+ is_ext_shared_channel: Optional[bool] = None
48
+ event_context: Optional[str] = None
49
+
50
+ def _normalize_path(self, path: str) -> str:
51
+ """Map the C# property alias ``event_content`` to JSON field ``event``."""
52
+ if path.lower() == "event_content":
53
+ return "event"
54
+ if path.lower().startswith("event_content."):
55
+ return "event" + path[len("event_content") :]
56
+ return path
@@ -0,0 +1,120 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any, Optional
10
+
11
+ from aiohttp import ClientSession, ClientTimeout
12
+ from pydantic import BaseModel
13
+
14
+ from .slack_response import SlackResponse, SlackResponseException
15
+
16
+ SLACK_API_BASE = "https://slack.com/api"
17
+
18
+
19
+ def _serialize_options(options: Any) -> str:
20
+ """Serialize the ``options`` argument to a JSON string suitable for Slack.
21
+
22
+ - ``None`` becomes ``"{}"``.
23
+ - A pre-built JSON string is returned as-is.
24
+ - A Pydantic model is dumped with ``exclude_none=True`` to match Slack's
25
+ tolerance of omitted fields and the C# implementation's null-stripping.
26
+ - Anything else is passed through :func:`json.dumps`, after recursively
27
+ removing ``None`` values from dicts/lists.
28
+ """
29
+ if options is None:
30
+ return "{}"
31
+ if isinstance(options, str):
32
+ return options
33
+ if isinstance(options, BaseModel):
34
+ return options.model_dump_json(by_alias=True, exclude_none=True)
35
+ return json.dumps(_strip_nones(options), ensure_ascii=False)
36
+
37
+
38
+ def _strip_nones(value: Any) -> Any:
39
+ if isinstance(value, dict):
40
+ return {k: _strip_nones(v) for k, v in value.items() if v is not None}
41
+ if isinstance(value, list):
42
+ return [_strip_nones(v) for v in value if v is not None]
43
+ if isinstance(value, BaseModel):
44
+ return value.model_dump(mode="json", by_alias=True, exclude_none=True)
45
+ return value
46
+
47
+
48
+ class SlackApi:
49
+ """
50
+ Async HTTP client for the Slack Web API.
51
+
52
+ Mirrors the C# ``SlackApi``: every call is a ``POST`` to
53
+ ``https://slack.com/api/{method}`` with a JSON body and an optional bearer
54
+ token. The response body is parsed into a :class:`SlackResponse`; a
55
+ :class:`SlackResponseException` is raised on a non-2xx HTTP status or when
56
+ Slack returns ``ok=false``.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ session: Optional[ClientSession] = None,
62
+ *,
63
+ base_url: str = SLACK_API_BASE,
64
+ request_timeout: float = 30.0,
65
+ ) -> None:
66
+ self._session = session
67
+ self._owns_session = session is None
68
+ self._base_url = base_url
69
+ self._timeout = ClientTimeout(total=request_timeout)
70
+
71
+ async def call(
72
+ self,
73
+ method: str,
74
+ options: Any = None,
75
+ token: str = "",
76
+ ) -> SlackResponse:
77
+ """Invoke a Slack Web API method.
78
+
79
+ :param method: The API method name (e.g. ``"chat.postMessage"``).
80
+ :param options: Request payload. May be ``None``, a JSON string, a
81
+ ``dict`` / ``list``, or a Pydantic model.
82
+ :param token: Bearer token. Sent as ``Authorization: Bearer {token}``
83
+ when non-empty.
84
+ :returns: The parsed :class:`SlackResponse`.
85
+ :raises ValueError: if ``method`` is empty or whitespace.
86
+ :raises SlackResponseException: on HTTP error or ``ok=false``.
87
+ """
88
+ if not method or not method.strip():
89
+ raise ValueError("method must be a non-empty string")
90
+
91
+ body = _serialize_options(options)
92
+ url = f"{self._base_url}/{method}"
93
+ headers = {"Content-Type": "application/json"}
94
+ if token and token.strip():
95
+ headers["Authorization"] = f"Bearer {token}"
96
+
97
+ session = self._session or ClientSession()
98
+ try:
99
+ async with session.post(
100
+ url, data=body, headers=headers, timeout=self._timeout
101
+ ) as response:
102
+ text = await response.text()
103
+ try:
104
+ payload = json.loads(text) if text else {}
105
+ data = SlackResponse.model_validate(payload)
106
+ except Exception as exc:
107
+ raise SlackResponseException(
108
+ f"Slack API error on {method} (HTTP {response.status}):\n{text}"
109
+ ) from exc
110
+
111
+ if not response.ok or not data.ok:
112
+ raise SlackResponseException(
113
+ f"Slack API error on {method} (HTTP {response.status}):\n{text}",
114
+ data,
115
+ )
116
+
117
+ return data
118
+ finally:
119
+ if self._owns_session:
120
+ await session.close()
@@ -0,0 +1,67 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from .action_payload import ActionPayload
13
+ from .event_envelope import EventEnvelope
14
+
15
+
16
+ class SlackChannelData(BaseModel):
17
+ """
18
+ Data associated with a Slack channel activity, as delivered by Azure Bot
19
+ Service in :attr:`Activity.channel_data`.
20
+
21
+ Bot Service historically named the envelope property ``SlackMessage`` and
22
+ the API token ``ApiToken``; this model accepts both the PascalCase JSON
23
+ names (via aliases) and snake_case Python names.
24
+ """
25
+
26
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
27
+
28
+ envelope: Optional[EventEnvelope] = Field(default=None, alias="SlackMessage")
29
+ payload: Optional[ActionPayload] = Field(default=None, alias="Payload")
30
+ api_token: Optional[str] = Field(default=None, alias="ApiToken")
31
+
32
+ @property
33
+ def channel(self) -> Optional[str]:
34
+ """Slack channel id, sourced from the envelope or action payload."""
35
+ if self.envelope is not None:
36
+ return self.envelope.get("event.channel")
37
+ if self.payload is not None:
38
+ return self.payload.get("channel")
39
+ return None
40
+
41
+ @property
42
+ def thread_ts(self) -> Optional[str]:
43
+ """Thread timestamp, sourced from the envelope event or payload message."""
44
+ if self.envelope is not None:
45
+ return self.envelope.get("event.thread_ts") or self.envelope.get("event.ts")
46
+ if self.payload is not None:
47
+ return self.payload.get("message.thread_ts") or self.payload.get(
48
+ "message.ts"
49
+ )
50
+ return None
51
+
52
+ @classmethod
53
+ def from_activity(cls, activity: Any) -> "SlackChannelData":
54
+ """Build a :class:`SlackChannelData` from an Activity's ``channel_data``.
55
+
56
+ Accepts ``Activity.channel_data`` that is either a ``dict`` (typical for
57
+ deserialized incoming requests) or already a :class:`SlackChannelData`.
58
+ Returns an empty instance when ``channel_data`` is missing.
59
+ """
60
+ data = getattr(activity, "channel_data", None) if activity is not None else None
61
+ if data is None:
62
+ return cls()
63
+ if isinstance(data, cls):
64
+ return data
65
+ if isinstance(data, BaseModel):
66
+ data = data.model_dump(mode="json", by_alias=True)
67
+ return cls.model_validate(data)
@@ -0,0 +1,73 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional, Type, TypeVar, overload
9
+
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ from .._path_navigator import try_get_path_value
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ class SlackModel(BaseModel):
18
+ """
19
+ Base class for Slack model objects that expose dot-notation path navigation via
20
+ :meth:`get` and :meth:`try_get`.
21
+
22
+ Subclasses are Pydantic models with ``extra="allow"`` so any unmodelled fields
23
+ from Slack are preserved and reachable by path. The path navigator walks the
24
+ serialized form (``by_alias=True``), so paths use the same snake_case names
25
+ that appear in the Slack docs.
26
+
27
+ Subclasses whose JSON field names differ from their Python property names
28
+ override :meth:`_normalize_path` to remap the alias before navigation (e.g.
29
+ :class:`EventEnvelope` maps ``event_content`` → ``event``).
30
+ """
31
+
32
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
33
+
34
+ def _data(self) -> dict[str, Any]:
35
+ """Return the serialized form used for path navigation. Subclasses with
36
+ their own backing store may override this."""
37
+ return self.model_dump(mode="json", by_alias=True, exclude_none=False)
38
+
39
+ def _normalize_path(self, path: str) -> str:
40
+ """Remap caller-supplied path before navigation. Default: identity."""
41
+ return path
42
+
43
+ @overload
44
+ def get(self, path: str) -> Any: ...
45
+ @overload
46
+ def get(self, path: str, default: T) -> T: ...
47
+ @overload
48
+ def get(self, path: str, type_: Type[T]) -> T: ...
49
+ @overload
50
+ def get(self, path: str, default: T, type_: Type[T]) -> T: ...
51
+
52
+ def get(
53
+ self, path: str, default: Optional[T] = None, type_: Type[T] = None
54
+ ) -> Optional[T]:
55
+ """Get a value at the dot-notation ``path``. Supports dot separators and
56
+ bracket array indexing (e.g. ``"message.attachments[0].text"``). Returns
57
+ ``default`` (or ``None``) when the path does not exist.
58
+
59
+ ``type_`` is accepted for API symmetry with the C# generic ``Get<T>`` but
60
+ is not enforced at runtime — Pydantic models keep values in their
61
+ deserialized shape.
62
+ """
63
+ if not path:
64
+ return self._data()
65
+
66
+ found, value = try_get_path_value(self._data(), self._normalize_path(path))
67
+ return value if found else default
68
+
69
+ def try_get(self, path: str) -> tuple[bool, Any]:
70
+ """Like :meth:`get`, but returns ``(found, value)``."""
71
+ if not path:
72
+ return True, self._data()
73
+ return try_get_path_value(self._data(), self._normalize_path(path))
@@ -0,0 +1,42 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Optional
9
+
10
+ from pydantic import ConfigDict
11
+
12
+ from .slack_model import SlackModel
13
+
14
+
15
+ class SlackResponse(SlackModel):
16
+ """
17
+ Response from a Slack Web API call.
18
+
19
+ Named properties cover the fields common to every Slack response. Any
20
+ additional fields returned by a specific method are accessible via
21
+ :meth:`SlackModel.get` using dot-notation paths::
22
+
23
+ response = await slack_api.call("chat.postMessage", options, token)
24
+ channel = response.get("channel")
25
+ msg_ts = response.get("message.ts")
26
+ """
27
+
28
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
29
+
30
+ ok: bool = False
31
+ error: Optional[str] = None
32
+ warning: Optional[str] = None
33
+ ts: Optional[str] = None
34
+ response_metadata: Optional[Any] = None
35
+
36
+
37
+ class SlackResponseException(Exception):
38
+ """Raised when a Slack Web API call returns a non-2xx status or ``ok=false``."""
39
+
40
+ def __init__(self, message: str, response: Optional[SlackResponse] = None) -> None:
41
+ super().__init__(message)
42
+ self.response = response
@@ -0,0 +1,160 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from typing import Any, Optional, Union
10
+
11
+ from pydantic import BaseModel
12
+
13
+ from .chunks import MarkdownTextChunk, TaskDisplayMode
14
+ from .slack_api import SlackApi
15
+
16
+
17
+ def _chunk_to_dict(chunk: Any) -> Any:
18
+ if isinstance(chunk, BaseModel):
19
+ return chunk.model_dump(mode="json", by_alias=True, exclude_none=True)
20
+ return chunk
21
+
22
+
23
+ class SlackStream:
24
+ """
25
+ Incrementally builds and updates a single Slack message via
26
+ ``chat.startStream`` / ``chat.appendStream`` / ``chat.stopStream``.
27
+
28
+ Not thread-safe; concurrent operations on the same instance produce
29
+ undefined behavior. Call :meth:`start` before :meth:`append`, and
30
+ :meth:`stop` when finished.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ slack_api: SlackApi,
36
+ channel: str,
37
+ thread_ts: str,
38
+ token: str,
39
+ ) -> None:
40
+ self._slack_api = slack_api
41
+ self._channel = channel
42
+ self._thread_ts = thread_ts
43
+ self._token = token
44
+ self._message_ts: Optional[str] = None
45
+
46
+ async def start(
47
+ self, task_display_mode: str = TaskDisplayMode.PLAN
48
+ ) -> "SlackStream":
49
+ """Start a new Slack stream.
50
+
51
+ See https://docs.slack.dev/reference/methods/chat.startStream
52
+ """
53
+ result = await self._slack_api.call(
54
+ "chat.startStream",
55
+ {
56
+ "channel": self._channel,
57
+ "thread_ts": self._thread_ts,
58
+ "task_display_mode": task_display_mode,
59
+ },
60
+ self._token,
61
+ )
62
+ self._message_ts = result.ts
63
+ return self
64
+
65
+ async def append(
66
+ self,
67
+ chunk_or_text: Union[str, BaseModel, list[BaseModel]],
68
+ ) -> "SlackStream":
69
+ """Append one or more chunks to the stream.
70
+
71
+ Accepts a plain string (wrapped in a :class:`MarkdownTextChunk`), a
72
+ single chunk model, or an iterable of chunk models.
73
+
74
+ See https://docs.slack.dev/reference/methods/chat.appendStream
75
+ """
76
+ if self._message_ts is None:
77
+ raise ValueError(
78
+ "SlackStream.start() must be called before append() "
79
+ "to establish the message timestamp required by Slack's API"
80
+ )
81
+
82
+ if chunk_or_text is None:
83
+ raise ValueError("chunk_or_text must not be None")
84
+
85
+ if isinstance(chunk_or_text, str):
86
+ chunks: list[Any] = [MarkdownTextChunk(text=chunk_or_text)]
87
+ elif isinstance(chunk_or_text, BaseModel):
88
+ chunks = [chunk_or_text]
89
+ else:
90
+ chunks = list(chunk_or_text)
91
+
92
+ if not chunks:
93
+ return self
94
+
95
+ await self._slack_api.call(
96
+ "chat.appendStream",
97
+ {
98
+ "channel": self._channel,
99
+ "ts": self._message_ts,
100
+ "thread_ts": self._thread_ts,
101
+ "chunks": [_chunk_to_dict(c) for c in chunks],
102
+ },
103
+ self._token,
104
+ )
105
+ return self
106
+
107
+ async def stop(
108
+ self,
109
+ chunks: Optional[list[BaseModel]] = None,
110
+ blocks: Union[str, list[Any], dict, None] = None,
111
+ ) -> None:
112
+ """Stop the active stream, optionally finalizing with chunks and/or
113
+ Block Kit blocks.
114
+
115
+ ``blocks`` may be a JSON-array string, a JSON-object string containing
116
+ a ``"blocks"`` array, a Python list of block dicts, or a dict with a
117
+ top-level ``"blocks"`` key.
118
+
119
+ See https://docs.slack.dev/reference/methods/chat.stopStream
120
+ """
121
+ if not self._message_ts:
122
+ return
123
+
124
+ resolved_blocks = self._resolve_blocks(blocks)
125
+ resolved_chunks = (
126
+ [_chunk_to_dict(c) for c in chunks] if chunks is not None else None
127
+ )
128
+
129
+ body: dict[str, Any] = {
130
+ "channel": self._channel,
131
+ "ts": self._message_ts,
132
+ "thread_ts": self._thread_ts,
133
+ }
134
+ if resolved_chunks is not None:
135
+ body["chunks"] = resolved_chunks
136
+ if resolved_blocks is not None:
137
+ body["blocks"] = resolved_blocks
138
+
139
+ await self._slack_api.call("chat.stopStream", body, self._token)
140
+
141
+ @staticmethod
142
+ def _resolve_blocks(blocks: Any) -> Optional[list[Any]]:
143
+ if blocks is None:
144
+ return None
145
+ if isinstance(blocks, str):
146
+ try:
147
+ parsed = json.loads(blocks)
148
+ except json.JSONDecodeError as exc:
149
+ raise ValueError("blocks string is not valid JSON") from exc
150
+ return SlackStream._resolve_blocks(parsed)
151
+ if isinstance(blocks, dict):
152
+ if "blocks" not in blocks or not isinstance(blocks["blocks"], list):
153
+ raise ValueError("blocks object must contain a 'blocks' array property")
154
+ return blocks["blocks"]
155
+ if isinstance(blocks, list):
156
+ return blocks
157
+ raise ValueError(
158
+ "blocks must be a JSON array, a JSON object with a 'blocks' array, "
159
+ "or a string encoding either"
160
+ )
@@ -0,0 +1,202 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Any, Callable, Generic, Optional, Pattern, TypeVar, Union
10
+
11
+ from microsoft_agents.activity import ActivityTypes, Channels
12
+ from microsoft_agents.hosting.core import TurnContext
13
+ from microsoft_agents.hosting.core.app import AgentApplication, RouteRank
14
+ from microsoft_agents.hosting.core.app.state import TurnState
15
+
16
+ from .api import (
17
+ SlackApi,
18
+ SlackChannelData,
19
+ SlackResponse,
20
+ SlackStream,
21
+ )
22
+
23
+ StateT = TypeVar("StateT", bound=TurnState)
24
+
25
+ TextSelector = Union[str, Pattern[str], None]
26
+
27
+ _SLACK_API_SERVICE_KEY = "microsoft_agents.hosting.slack.SlackApi"
28
+
29
+
30
+ def _matches_text(selector: TextSelector, text: Optional[str]) -> bool:
31
+ if selector is None:
32
+ return True
33
+ if text is None:
34
+ return False
35
+ if isinstance(selector, Pattern):
36
+ return re.fullmatch(selector, text) is not None
37
+ return text == selector
38
+
39
+
40
+ def _is_slack_channel(context: TurnContext) -> bool:
41
+ return context.activity.channel_id == Channels.slack.value
42
+
43
+
44
+ class SlackAgentExtension(Generic[StateT]):
45
+ """
46
+ Slack-specific route registration and helpers for an
47
+ :class:`~microsoft_agents.hosting.core.app.AgentApplication`.
48
+
49
+ Usage::
50
+
51
+ from microsoft_agents.hosting.slack import SlackAgentExtension
52
+ from microsoft_agents.hosting.slack.api import SlackChannelData
53
+
54
+ app = AgentApplication(options)
55
+ slack = SlackAgentExtension(app)
56
+
57
+ @slack.on_message("hello")
58
+ async def greet(context, state):
59
+ channel_data = SlackChannelData.from_activity(context.activity)
60
+ await slack.call(
61
+ context,
62
+ "chat.postMessage",
63
+ {
64
+ "channel": channel_data.channel,
65
+ "text": f"Hi, {context.activity.from_property.name}!",
66
+ },
67
+ token=channel_data.api_token,
68
+ )
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ application: AgentApplication[StateT],
74
+ *,
75
+ slack_api: Optional[SlackApi] = None,
76
+ ) -> None:
77
+ self._app = application
78
+ self._slack_api = slack_api or SlackApi()
79
+
80
+ @property
81
+ def slack_api(self) -> SlackApi:
82
+ """The shared :class:`SlackApi` used by :meth:`call` and :meth:`create_stream`."""
83
+ return self._slack_api
84
+
85
+ # ── direct Slack API access ────────────────────────────────────────────
86
+
87
+ async def call(
88
+ self,
89
+ turn_context: TurnContext,
90
+ method: str,
91
+ options: Any = None,
92
+ token: str = "",
93
+ ) -> SlackResponse:
94
+ """Invoke a Slack Web API method, preferring a per-turn :class:`SlackApi`
95
+ if one has been cached on ``turn_context.services``."""
96
+ api = self._slack_api
97
+ if turn_context is not None and turn_context.has(_SLACK_API_SERVICE_KEY):
98
+ api = turn_context.get(_SLACK_API_SERVICE_KEY) # type: ignore[assignment]
99
+ return await api.call(method, options, token)
100
+
101
+ async def create_stream(
102
+ self,
103
+ turn_context: TurnContext,
104
+ thread_ts: Optional[str] = None,
105
+ ) -> SlackStream:
106
+ """Create and start a :class:`SlackStream` for the current Slack thread."""
107
+ channel_data = SlackChannelData.from_activity(turn_context.activity)
108
+ if channel_data.envelope is None:
109
+ raise ValueError(
110
+ "create_stream requires a Slack event envelope on the activity"
111
+ )
112
+ resolved_thread_ts = thread_ts or channel_data.envelope.get("event.ts")
113
+ api = self._slack_api
114
+ if turn_context.has(_SLACK_API_SERVICE_KEY):
115
+ api = turn_context.get(_SLACK_API_SERVICE_KEY) # type: ignore[assignment]
116
+ stream = SlackStream(
117
+ api,
118
+ channel_data.envelope.get("event.channel"),
119
+ resolved_thread_ts,
120
+ channel_data.api_token or "",
121
+ )
122
+ return await stream.start()
123
+
124
+ # ── message routes ─────────────────────────────────────────────────────
125
+
126
+ def on_message(
127
+ self,
128
+ select: TextSelector = None,
129
+ *,
130
+ auth_handlers: Optional[list[str]] = None,
131
+ rank: RouteRank = RouteRank.DEFAULT,
132
+ ) -> Callable:
133
+ """Register a handler for Slack message activities.
134
+
135
+ When ``select`` is ``None``, every Slack message matches; otherwise the
136
+ activity's ``text`` must equal ``select`` (string) or fully match it
137
+ (compiled regex).
138
+
139
+ ``rank`` defaults to :attr:`RouteRank.DEFAULT`; when ``select`` is
140
+ ``None`` the rank is downgraded to :attr:`RouteRank.LAST` so explicit
141
+ text routes win — matching the C# behavior.
142
+ """
143
+ effective_rank = (
144
+ RouteRank.LAST if (select is None and rank == RouteRank.DEFAULT) else rank
145
+ )
146
+
147
+ def __selector(context: TurnContext) -> bool:
148
+ if context.activity.type != ActivityTypes.message or not _is_slack_channel(
149
+ context
150
+ ):
151
+ return False
152
+ return _matches_text(select, context.activity.text)
153
+
154
+ def __call(func: Callable) -> Callable:
155
+ self._app.add_route(
156
+ __selector,
157
+ func,
158
+ rank=effective_rank,
159
+ auth_handlers=auth_handlers,
160
+ )
161
+ return func
162
+
163
+ return __call
164
+
165
+ # ── event routes ───────────────────────────────────────────────────────
166
+
167
+ def on_event(
168
+ self,
169
+ event_name: TextSelector = None,
170
+ *,
171
+ auth_handlers: Optional[list[str]] = None,
172
+ rank: RouteRank = RouteRank.DEFAULT,
173
+ ) -> Callable:
174
+ """Register a handler for Slack event activities.
175
+
176
+ When ``event_name`` is ``None``, every Slack event matches; otherwise
177
+ the activity's ``name`` must equal it (string) or fully match it
178
+ (compiled regex).
179
+ """
180
+ effective_rank = (
181
+ RouteRank.LAST
182
+ if (event_name is None and rank == RouteRank.DEFAULT)
183
+ else rank
184
+ )
185
+
186
+ def __selector(context: TurnContext) -> bool:
187
+ if context.activity.type != ActivityTypes.event or not _is_slack_channel(
188
+ context
189
+ ):
190
+ return False
191
+ return _matches_text(event_name, context.activity.name)
192
+
193
+ def __call(func: Callable) -> Callable:
194
+ self._app.add_route(
195
+ __selector,
196
+ func,
197
+ rank=effective_rank,
198
+ auth_handlers=auth_handlers,
199
+ )
200
+ return func
201
+
202
+ return __call
@@ -0,0 +1,61 @@
1
+ """
2
+ Copyright (c) Microsoft Corporation. All rights reserved.
3
+ Licensed under the MIT License.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Optional
9
+
10
+
11
+ def slack_encode(value: Optional[str]) -> Optional[str]:
12
+ """Encode text for Slack. See https://api.slack.com/docs/message-formatting."""
13
+ if value is None:
14
+ return None
15
+ return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
16
+
17
+
18
+ def slack_decode(value: Optional[str]) -> Optional[str]:
19
+ """Decode text from Slack. See https://api.slack.com/docs/message-formatting."""
20
+ if value is None:
21
+ return None
22
+ return value.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">")
23
+
24
+
25
+ def create_conversation_id(
26
+ slack_bot_id: str,
27
+ slack_team_id: str,
28
+ slack_channel_id: str,
29
+ slack_thread_ts: Optional[str] = None,
30
+ ) -> str:
31
+ """Compose a Bot Service-compatible conversation id from Slack identifiers."""
32
+ if slack_thread_ts is None:
33
+ return f"{slack_bot_id}:{slack_team_id}:{slack_channel_id}"
34
+ return f"{slack_bot_id}:{slack_team_id}:{slack_channel_id}:{slack_thread_ts}"
35
+
36
+
37
+ def _from_conversation_id(conversation_id: str, pos: int) -> Optional[str]:
38
+ if conversation_id is None or not conversation_id.strip():
39
+ raise ValueError("conversation_id must be a non-empty string")
40
+ parts = conversation_id.split(":")
41
+ if len(parts) not in (3, 4):
42
+ raise ValueError(f"Invalid conversation_id: {conversation_id}")
43
+ if pos >= len(parts):
44
+ return None
45
+ return parts[pos]
46
+
47
+
48
+ def slack_bot_id_from_conversation_id(conversation_id: str) -> Optional[str]:
49
+ return _from_conversation_id(conversation_id, 0)
50
+
51
+
52
+ def slack_team_id_from_conversation_id(conversation_id: str) -> Optional[str]:
53
+ return _from_conversation_id(conversation_id, 1)
54
+
55
+
56
+ def slack_channel_id_from_conversation_id(conversation_id: str) -> Optional[str]:
57
+ return _from_conversation_id(conversation_id, 2)
58
+
59
+
60
+ def slack_thread_ts_from_conversation_id(conversation_id: str) -> Optional[str]:
61
+ return _from_conversation_id(conversation_id, 3)
@@ -0,0 +1,71 @@
1
+ Metadata-Version: 2.4
2
+ Name: microsoft-agents-hosting-slack
3
+ Version: 1.1.0.dev8
4
+ Summary: Integration library for Microsoft Agents with Slack
5
+ Author: Microsoft Corporation
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/microsoft/Agents
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: microsoft-agents-hosting-core==1.1.0.dev8
19
+ Requires-Dist: aiohttp>=3.11.11
20
+ Dynamic: license-file
21
+ Dynamic: requires-dist
22
+
23
+ # Microsoft Agents Hosting - Slack
24
+
25
+ [![PyPI version](https://img.shields.io/pypi/v/microsoft-agents-hosting-slack)](https://pypi.org/project/microsoft-agents-hosting-slack/)
26
+
27
+ 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`.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install microsoft-agents-hosting-slack
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```python
38
+ from microsoft_agents.hosting.core.app import AgentApplication
39
+ from microsoft_agents.hosting.slack import SlackAgentExtension
40
+ from microsoft_agents.hosting.slack.api import SlackChannelData
41
+
42
+ app = AgentApplication(options)
43
+ slack = SlackAgentExtension(app)
44
+
45
+ @slack.on_message()
46
+ async def on_slack_message(context, state):
47
+ channel_data = SlackChannelData.from_activity(context.activity)
48
+ await slack.call(
49
+ context,
50
+ "chat.postMessage",
51
+ {
52
+ "channel": channel_data.get("event.channel"),
53
+ "text": f"You said: {context.activity.text}",
54
+ },
55
+ token=channel_data.api_token,
56
+ )
57
+ ```
58
+
59
+ ## Key Classes
60
+
61
+ - **`SlackAgentExtension`** — registers Slack-channel-scoped message/event handlers; exposes `call(...)` and `create_stream(...)`.
62
+ - **`SlackChannelData`** — typed wrapper around Bot Service's Slack channel-data payload with `get(path)` / `try_get(path)` accessors.
63
+ - **`SlackApi`** — async HTTP client for the Slack Web API.
64
+ - **`SlackStream`** — wraps Slack's streaming methods for incremental message updates.
65
+ - **`SlackHelpers`** — encode/decode and conversation-id parsing utilities.
66
+
67
+ # Quick Links
68
+
69
+ - 📦 [All SDK Packages on PyPI](https://pypi.org/search/?q=microsoft-agents)
70
+ - 📖 [Complete Documentation](https://aka.ms/agents)
71
+ - 🐛 [Report Issues](https://github.com/microsoft/Agents-for-python/issues)
@@ -0,0 +1,19 @@
1
+ microsoft_agents/hosting/slack/__init__.py,sha256=cyaF_M7-BcGs-WmGWWMF7gqOXsQPe2qPbzjxrawcAFw,697
2
+ microsoft_agents/hosting/slack/_path_navigator.py,sha256=fUgaOVipjH-ad8mqaokNb8qmB_XjWZcFE1B-QfJcETk,3048
3
+ microsoft_agents/hosting/slack/slack_agent_extension.py,sha256=vUx80m6Z58Xb14hw8RiYAxtTsKxALp3hmmLvRFdwzCg,6982
4
+ microsoft_agents/hosting/slack/slack_helpers.py,sha256=jJ_P8hNOR5nOhiKr5gVQU0lfKpBsvCzxUOrLrXttdLs,2082
5
+ microsoft_agents/hosting/slack/api/__init__.py,sha256=6ORph89cJLGgeZioNOJHRyjZWxFdhUZf6bOLuAF45zU,977
6
+ microsoft_agents/hosting/slack/api/action_payload.py,sha256=4uW49YYsLaPt1RcVFcNyA-v4398-2KgU28rvZySEiH8,627
7
+ microsoft_agents/hosting/slack/api/chunks.py,sha256=3A49eF8gGMovl0dfVNEakRs5klUtmnA84WPQ8RbWD2o,1995
8
+ microsoft_agents/hosting/slack/api/event_content.py,sha256=nTL68Ues16GaASZ0754adcdTNuvboVS9oc0uzFA_r7c,1367
9
+ microsoft_agents/hosting/slack/api/event_envelope.py,sha256=HaH4oRonA59GMDRgvT7V0b8HOSDbfSLz74bbq2mXoS4,1920
10
+ microsoft_agents/hosting/slack/api/slack_api.py,sha256=bUyghKap9tR-BbNWEOhuV7Fhf_q7A0BcYHYFN017OLI,4288
11
+ microsoft_agents/hosting/slack/api/slack_channel_data.py,sha256=QQieLewDBH7QPaBZahs9qIDBz7AjmWwPUlYIGUBQIWg,2506
12
+ microsoft_agents/hosting/slack/api/slack_model.py,sha256=wVonGP3Fl0zHjl8dsZx2cRFzG5GENM7nSR3UMMJdggY,2692
13
+ microsoft_agents/hosting/slack/api/slack_response.py,sha256=_hNOSxeovDlBd5tnwEqYnZ3fM3f122Gas5xS6p87Tf0,1207
14
+ microsoft_agents/hosting/slack/api/slack_stream.py,sha256=Icsu7V7gJvZQOmoUXX3ALf-lB4DlWaXAH8Z2NyL-ip8,5132
15
+ microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/licenses/LICENSE,sha256=ws_MuBL-SCEBqPBFl9_FqZkaaydIJmxHrJG2parhU4M,1141
16
+ microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/METADATA,sha256=m1uaw-BaQ7xEXO6aOpvdftHHqfVvwuqDURWFCBLvHWQ,2832
17
+ microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
18
+ microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/top_level.txt,sha256=lWKcT4v6fTA_NgsuHdNvuMjSrkiBMXohn64ApY7Xi8A,17
19
+ microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Microsoft Corporation.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE
@@ -0,0 +1 @@
1
+ microsoft_agents