microsoft-agents-hosting-slack 1.1.0__tar.gz

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.
Files changed (27) hide show
  1. microsoft_agents_hosting_slack-1.1.0/LICENSE +21 -0
  2. microsoft_agents_hosting_slack-1.1.0/MANIFEST.in +1 -0
  3. microsoft_agents_hosting_slack-1.1.0/PKG-INFO +89 -0
  4. microsoft_agents_hosting_slack-1.1.0/VERSION.txt +1 -0
  5. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/__init__.py +26 -0
  6. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/_path_navigator.py +108 -0
  7. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/__init__.py +42 -0
  8. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/action_payload.py +26 -0
  9. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/chunks.py +77 -0
  10. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/event_content.py +45 -0
  11. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/event_envelope.py +56 -0
  12. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/slack_api.py +120 -0
  13. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/slack_channel_data.py +67 -0
  14. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/slack_model.py +73 -0
  15. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/slack_response.py +42 -0
  16. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/api/slack_stream.py +160 -0
  17. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/slack_agent_extension.py +202 -0
  18. microsoft_agents_hosting_slack-1.1.0/microsoft_agents/hosting/slack/slack_helpers.py +61 -0
  19. microsoft_agents_hosting_slack-1.1.0/microsoft_agents_hosting_slack.egg-info/PKG-INFO +89 -0
  20. microsoft_agents_hosting_slack-1.1.0/microsoft_agents_hosting_slack.egg-info/SOURCES.txt +25 -0
  21. microsoft_agents_hosting_slack-1.1.0/microsoft_agents_hosting_slack.egg-info/dependency_links.txt +1 -0
  22. microsoft_agents_hosting_slack-1.1.0/microsoft_agents_hosting_slack.egg-info/requires.txt +2 -0
  23. microsoft_agents_hosting_slack-1.1.0/microsoft_agents_hosting_slack.egg-info/top_level.txt +1 -0
  24. microsoft_agents_hosting_slack-1.1.0/pyproject.toml +25 -0
  25. microsoft_agents_hosting_slack-1.1.0/readme.md +67 -0
  26. microsoft_agents_hosting_slack-1.1.0/setup.cfg +4 -0
  27. microsoft_agents_hosting_slack-1.1.0/setup.py +18 -0
@@ -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
+ include VERSION.txt
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.4
2
+ Name: microsoft-agents-hosting-slack
3
+ Version: 1.1.0
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
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
+ ## Release Notes
30
+ <table style="width:100%">
31
+ <tr>
32
+ <th style="width:20%">Version</th>
33
+ <th style="width:20%">Date</th>
34
+ <th style="width:60%">Release Notes</th>
35
+ </tr>
36
+ <tr>
37
+ <td>1.1.0</td>
38
+ <td>2026-06-19</td>
39
+ <td>
40
+ <a href="https://github.com/microsoft/Agents-for-python/blob/main/changelog.md#microsoft-365-agents-sdk-for-python---release-notes-v110">
41
+ 1.1.0 Release Notes
42
+ </a>
43
+ </td>
44
+ </tr>
45
+ </table>
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install microsoft-agents-hosting-slack
51
+ ```
52
+
53
+ ## Usage
54
+
55
+ ```python
56
+ from microsoft_agents.hosting.core.app import AgentApplication
57
+ from microsoft_agents.hosting.slack import SlackAgentExtension
58
+ from microsoft_agents.hosting.slack.api import SlackChannelData
59
+
60
+ app = AgentApplication(options)
61
+ slack = SlackAgentExtension(app)
62
+
63
+ @slack.on_message()
64
+ async def on_slack_message(context, state):
65
+ channel_data = SlackChannelData.from_activity(context.activity)
66
+ await slack.call(
67
+ context,
68
+ "chat.postMessage",
69
+ {
70
+ "channel": channel_data.get("event.channel"),
71
+ "text": f"You said: {context.activity.text}",
72
+ },
73
+ token=channel_data.api_token,
74
+ )
75
+ ```
76
+
77
+ ## Key Classes
78
+
79
+ - **`SlackAgentExtension`** — registers Slack-channel-scoped message/event handlers; exposes `call(...)` and `create_stream(...)`.
80
+ - **`SlackChannelData`** — typed wrapper around Bot Service's Slack channel-data payload with `get(path)` / `try_get(path)` accessors.
81
+ - **`SlackApi`** — async HTTP client for the Slack Web API.
82
+ - **`SlackStream`** — wraps Slack's streaming methods for incremental message updates.
83
+ - **`SlackHelpers`** — encode/decode and conversation-id parsing utilities.
84
+
85
+ # Quick Links
86
+
87
+ - 📦 [All SDK Packages on PyPI](https://pypi.org/search/?q=microsoft-agents)
88
+ - 📖 [Complete Documentation](https://aka.ms/agents)
89
+ - 🐛 [Report Issues](https://github.com/microsoft/Agents-for-python/issues)
@@ -0,0 +1 @@
1
+ 1.1.0
@@ -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
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)