Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .azdo/ci-pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ steps:
else
echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+"
fi
python -m pip install ./dist/microsoft_agents_hosting_slack*.whl
python -m pip install ./dist/microsoft_agents_storage_blob*.whl
python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl
displayName: 'Install wheels'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ jobs:
else
echo "Skipping microsoft_agents_hosting_teams: requires Python 3.12+"
fi
python -m pip install ./dist/microsoft_agents_hosting_slack*.whl
python -m pip install ./dist/microsoft_agents_storage_blob*.whl
python -m pip install ./dist/microsoft_agents_storage_cosmos*.whl
- name: Test with pytest
Expand Down
21 changes: 21 additions & 0 deletions libraries/microsoft-agents-hosting-slack/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) Microsoft Corporation.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
1 change: 1 addition & 0 deletions libraries/microsoft-agents-hosting-slack/MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include VERSION.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from .slack_agent_extension import SlackAgentExtension
from .slack_helpers import (
create_conversation_id,
slack_bot_id_from_conversation_id,
slack_channel_id_from_conversation_id,
slack_decode,
slack_encode,
slack_team_id_from_conversation_id,
slack_thread_ts_from_conversation_id,
)

__all__ = [
"SlackAgentExtension",
"create_conversation_id",
"slack_bot_id_from_conversation_id",
"slack_channel_id_from_conversation_id",
"slack_decode",
"slack_encode",
"slack_team_id_from_conversation_id",
"slack_thread_ts_from_conversation_id",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from __future__ import annotations

from typing import Any, Optional, Tuple


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

Supports ``a.b.c``, ``a[0].b``, ``a[1][2]``. Integer brackets become ``int``
segments; everything else becomes a string segment. Returns ``None`` for
malformed bracket nesting.
"""
if not path:
return []

segments: list[Any] = []
i = 0
start = 0

def emit() -> None:
nonlocal start
if start < i:
segments.append(path[start:i])
start = i + 1

while i < len(path):
ch = path[i]
if ch == ".":
emit()
elif ch == "[":
emit()
nesting = 1
i += 1
inner_start = i
while i < len(path):
c = path[i]
if c == "[":
nesting += 1
elif c == "]":
nesting -= 1
if nesting == 0:
break
i += 1
if nesting != 0:
return None
inner = path[inner_start:i]
if inner.isdigit() or (inner.startswith("-") and inner[1:].isdigit()):
segments.append(int(inner))
else:
segments.append(inner)
start = i + 1
i += 1

emit()
return segments


def _resolve_segment(current: Any, segment: Any) -> Tuple[bool, Any]:
"""Resolve one path segment against the current node.

Returns ``(found, value)``. ``found`` is False when the segment cannot be
resolved (missing key, out-of-range index, primitive node).
"""
if current is None:
return False, None

if isinstance(segment, int):
if isinstance(current, (list, tuple)):
if -len(current) <= segment < len(current):
return True, current[segment]
return False, None
return False, None

# string segment → dict key (case-sensitive fast path, case-insensitive fallback)
if isinstance(current, dict):
if segment in current:
return True, current[segment]
lower = segment.lower()
for key, value in current.items():
if isinstance(key, str) and key.lower() == lower:
return True, value
return False, None

return False, None


def try_get_path_value(data: Any, path: str) -> Tuple[bool, Any]:
"""Walk ``path`` against ``data``. Returns ``(found, value)``."""
if data is None:
return False, None
if not path:
return True, data

segments = _parse_path(path)
if segments is None:
return False, None

current: Any = data
for segment in segments:
found, current = _resolve_segment(current, segment)
if not found:
return False, None
return True, current
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from .action_payload import ActionPayload
from .chunks import (
BlocksChunk,
Chunk,
MarkdownTextChunk,
SlackTaskStatus,
Source,
TaskDisplayMode,
TaskUpdateChunk,
)
from .event_content import EventContent
from .event_envelope import EventEnvelope
from .slack_api import SLACK_API_BASE, SlackApi
from .slack_channel_data import SlackChannelData
from .slack_model import SlackModel
from .slack_response import SlackResponse, SlackResponseException
from .slack_stream import SlackStream

__all__ = [
"ActionPayload",
"BlocksChunk",
"Chunk",
"EventContent",
"EventEnvelope",
"MarkdownTextChunk",
"SLACK_API_BASE",
"SlackApi",
"SlackChannelData",
"SlackModel",
"SlackResponse",
"SlackResponseException",
"SlackStream",
"SlackTaskStatus",
"Source",
"TaskDisplayMode",
"TaskUpdateChunk",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from __future__ import annotations

from typing import Any, Optional

from pydantic import ConfigDict

from .slack_model import SlackModel


class ActionPayload(SlackModel):
"""
Interactive Message / Block Kit action payload from Slack. Sent when a user
clicks a button or interacts with a block element.
"""

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

type: Optional[str] = None
channel: Optional[Any] = None
message: Optional[Any] = None
actions: Optional[Any] = None
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.

Streaming chunk shapes for Slack ``chat.appendStream`` / ``chat.stopStream``.
See https://docs.slack.dev/reference/methods/chat.appendStream
"""

from __future__ import annotations

from typing import Any, Optional

from pydantic import BaseModel, ConfigDict, Field


class SlackTaskStatus:
"""Status values accepted by :class:`TaskUpdateChunk`."""

PENDING = "pending"
IN_PROGRESS = "in_progress"
COMPLETE = "complete"
ERROR = "error"


class TaskDisplayMode:
"""Values accepted by ``chat.startStream``'s ``task_display_mode``."""

PLAN = "plan"
TIMELINE = "timeline"


class Source(BaseModel):
"""Citation/source attached to a :class:`TaskUpdateChunk`."""

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

type: str = "url"
url: str = ""
text: str = ""


class MarkdownTextChunk(BaseModel):
"""Append a chunk of markdown-formatted text to a Slack stream."""

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

type: str = Field(default="markdown_text", frozen=True)
text: str = ""


class BlocksChunk(BaseModel):
"""Append a chunk of Block Kit blocks to a Slack stream."""

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

type: str = Field(default="blocks", frozen=True)
blocks: list[Any] = Field(default_factory=list)


class TaskUpdateChunk(BaseModel):
"""Append a task-status update chunk to a Slack stream."""

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

type: str = Field(default="task_update", frozen=True)
id: str
title: str
status: str = SlackTaskStatus.IN_PROGRESS
details: Optional[str] = None
output: Optional[str] = None
sources: Optional[list[Source]] = None


# Type alias for any chunk variant.
Chunk = (
BaseModel # all chunk classes are BaseModel subclasses with a `type` discriminator
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from __future__ import annotations

from typing import Optional

from pydantic import ConfigDict

from .slack_model import SlackModel


class EventContent(SlackModel):
"""
The inner ``event`` object from a Slack Events API callback payload.

Slack calls this the "event content". Because event payloads vary so widely
by ``type``, every unmodelled field is preserved via Pydantic's
``extra="allow"`` and is reachable through :meth:`SlackModel.get` using the
same snake_case names shown in the Slack docs.

See https://docs.slack.dev/reference/events
"""

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

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

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

# ── reaction_added / reaction_removed event fields ──
reaction: Optional[str] = None
item_user: Optional[str] = None
Loading
Loading