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.
- microsoft_agents/hosting/slack/__init__.py +26 -0
- microsoft_agents/hosting/slack/_path_navigator.py +108 -0
- microsoft_agents/hosting/slack/api/__init__.py +42 -0
- microsoft_agents/hosting/slack/api/action_payload.py +26 -0
- microsoft_agents/hosting/slack/api/chunks.py +77 -0
- microsoft_agents/hosting/slack/api/event_content.py +45 -0
- microsoft_agents/hosting/slack/api/event_envelope.py +56 -0
- microsoft_agents/hosting/slack/api/slack_api.py +120 -0
- microsoft_agents/hosting/slack/api/slack_channel_data.py +67 -0
- microsoft_agents/hosting/slack/api/slack_model.py +73 -0
- microsoft_agents/hosting/slack/api/slack_response.py +42 -0
- microsoft_agents/hosting/slack/api/slack_stream.py +160 -0
- microsoft_agents/hosting/slack/slack_agent_extension.py +202 -0
- microsoft_agents/hosting/slack/slack_helpers.py +61 -0
- microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/METADATA +71 -0
- microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/RECORD +19 -0
- microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/WHEEL +5 -0
- microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/licenses/LICENSE +21 -0
- microsoft_agents_hosting_slack-1.1.0.dev8.dist-info/top_level.txt +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, 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("&", "&").replace("<", "<").replace(">", ">")
|
|
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("&", "&").replace("<", "<").replace(">", ">")
|
|
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
|
+
[](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,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
|