Skip to content
Merged
2 changes: 1 addition & 1 deletion dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ pytest
pytest-parametrization>=2022.2.1
pre-commit
mypy

deepdiff
# MyPy stubs
types-requests
networkx-stubs
Expand Down
9 changes: 8 additions & 1 deletion elementary/messages/block_builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
LineBlock,
LinesBlock,
LinkBlock,
MentionBlock,
TextBlock,
TextStyle,
)
Expand Down Expand Up @@ -41,7 +42,9 @@ def BulletListBlock(
icon_inline: InlineBlock = (
IconBlock(icon=icon) if isinstance(icon, Icon) else TextBlock(text=icon)
)
lines = [LineBlock(inlines=[icon_inline] + line.inlines) for line in lines]
lines = [
LineBlock(inlines=[icon_inline] + line.inlines, sep=line.sep) for line in lines
]
return LinesBlock(lines=lines)


Expand Down Expand Up @@ -138,3 +141,7 @@ def TitledParagraphBlock(

def JsonCodeBlock(*, content: Union[str, dict, list], indent: int = 2) -> CodeBlock:
return CodeBlock(text=json.dumps(content, indent=indent))


def MentionLineBlock(*users: str) -> LineBlock:
return LineBlock(inlines=[MentionBlock(user=user) for user in users], sep=", ")
35 changes: 27 additions & 8 deletions elementary/messages/blocks.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from enum import Enum
from typing import List, Optional, Union
from typing import List, Optional, Sequence, Union

from pydantic import BaseModel
from typing_extensions import Literal
Expand Down Expand Up @@ -51,7 +51,32 @@ class IconBlock(BaseInlineTextBlock):
icon: Icon


InlineBlock = Union[TextBlock, LinkBlock, IconBlock]
class InlineCodeBlock(BaseInlineTextBlock):
type: Literal["inline_code"] = "inline_code"
code: str


class MentionBlock(BaseInlineTextBlock):
type: Literal["mention"] = "mention"
user: str


class LineBlock(BaseBlock):
type: Literal["line"] = "line"
inlines: Sequence["InlineBlock"]
sep: str = " "


InlineBlock = Union[
TextBlock,
LinkBlock,
IconBlock,
InlineCodeBlock,
MentionBlock,
"LineBlock",
]

LineBlock.update_forward_refs()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needed to join a subsection of the line with a different sep (the owners with a ',')



class HeaderBlock(BaseBlock):
Expand All @@ -68,12 +93,6 @@ class DividerBlock(BaseBlock):
type: Literal["divider"] = "divider"


class LineBlock(BaseBlock):
type: Literal["line"] = "line"
inlines: List[InlineBlock]
sep: str = " "


class BaseLinesBlock(BaseBlock):
lines: List[LineBlock]

Expand Down
8 changes: 8 additions & 0 deletions elementary/messages/formats/adaptive_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
Icon,
IconBlock,
InlineBlock,
InlineCodeBlock,
LineBlock,
LinesBlock,
LinkBlock,
MentionBlock,
TextBlock,
TextStyle,
)
Expand Down Expand Up @@ -46,6 +48,12 @@ def format_inline_block(block: InlineBlock) -> str:
return format_text_block(block)
elif isinstance(block, LinkBlock):
return f"[{block.text}]({block.url})"
elif isinstance(block, InlineCodeBlock):
return block.code
elif isinstance(block, MentionBlock):
return block.user
elif isinstance(block, LineBlock):
return format_line_block_text(block)
else:
raise ValueError(f"Unsupported inline block type: {type(block)}")

Expand Down
43 changes: 31 additions & 12 deletions elementary/messages/formats/block_kit.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Dict, List, Optional, Tuple
from typing import Callable, List, Optional, Tuple, TypedDict, cast

from slack_sdk.models import blocks as slack_blocks

Expand All @@ -12,9 +12,11 @@
Icon,
IconBlock,
InlineBlock,
InlineCodeBlock,
LineBlock,
LinesBlock,
LinkBlock,
MentionBlock,
TextBlock,
TextStyle,
)
Expand All @@ -28,14 +30,25 @@
}


class FormattedBlockKitMessage(TypedDict):
blocks: List[dict]
attachments: List[dict]


ResolveMentionCallback = Callable[[str], Optional[str]]


class BlockKitBuilder:
_SECONDARY_FACT_CHUNK_SIZE = 2
_LONGEST_MARKDOWN_SUFFIX_LEN = 3 # length of markdown's code suffix (```)

def __init__(self) -> None:
def __init__(
self, resolve_mention: Optional[ResolveMentionCallback] = None
) -> None:
self._blocks: List[dict] = []
self._attachment_blocks: List[dict] = []
self._is_divided = False
self._resolve_mention = resolve_mention or (lambda x: None)

def _format_icon(self, icon: Icon) -> str:
return ICON_TO_HTML[icon]
Expand All @@ -55,6 +68,16 @@ def _format_inline_block(self, block: InlineBlock) -> str:
return self._format_text_block(block)
elif isinstance(block, LinkBlock):
return f"<{block.url}|{block.text}>"
elif isinstance(block, InlineCodeBlock):
return f"`{block.code}`"
elif isinstance(block, MentionBlock):
resolved_user = self._resolve_mention(block.user)
if resolved_user:
return f"<@{resolved_user}>"
else:
return block.user
elif isinstance(block, LineBlock):
return self._format_line_block_text(block)
else:
raise ValueError(f"Unsupported inline block type: {type(block)}")

Expand Down Expand Up @@ -162,12 +185,6 @@ def _add_expandable_block(self, block: ExpandableBlock) -> None:
Expandable blocks are not supported in Slack Block Kit.
However, slack automatically collapses a large section block into an expandable block.
"""
self._add_block(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adding the button label as a title in slack looked really bad, looks better without it

{
"type": "section",
"text": self._format_markdown_section_text(f"*{block.title}*"),
}
)
self._add_message_blocks(block.body)

def _add_message_block(self, block: MessageBlock) -> None:
Expand Down Expand Up @@ -207,7 +224,7 @@ def _get_final_blocks(
else:
return [], self._blocks

def build(self, message: MessageBody) -> Dict[str, Any]:
def build(self, message: MessageBody) -> FormattedBlockKitMessage:
self._blocks = []
self._attachment_blocks = []
self._add_message_blocks(message.blocks)
Expand All @@ -223,9 +240,11 @@ def build(self, message: MessageBody) -> Dict[str, Any]:
}
if color_code:
built_message["attachments"][0]["color"] = color_code
return built_message
return cast(FormattedBlockKitMessage, built_message)


def format_block_kit(message: MessageBody) -> Dict[str, Any]:
builder = BlockKitBuilder()
def format_block_kit(
message: MessageBody, resolve_mention: Optional[ResolveMentionCallback] = None
) -> FormattedBlockKitMessage:
builder = BlockKitBuilder(resolve_mention)
return builder.build(message)
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,5 @@ def reply_to_message(
body: MessageBody,
) -> MessageSendResult[MessageContextType]:
if not self.supports_reply():
raise MessageIntegrationReplyNotSupportedError
raise MessageIntegrationReplyNotSupportedError(type(self).__name__)
raise NotImplementedError
4 changes: 3 additions & 1 deletion elementary/messages/messaging_integrations/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ class MessagingIntegrationError(Exception):


class MessageIntegrationReplyNotSupportedError(MessagingIntegrationError):
pass
def __init__(self, integration_name: str):
self.integration_name = integration_name
super().__init__(f"{integration_name} does not support replying to messages")
160 changes: 160 additions & 0 deletions elementary/messages/messaging_integrations/slack_web.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import json
from typing import Dict, Iterator, NewType, Optional, TypeAlias

from pydantic import BaseModel
from ratelimit import limits, sleep_and_retry
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler

from elementary.messages.formats.block_kit import (
FormattedBlockKitMessage,
format_block_kit,
)
from elementary.messages.message_body import MessageBody
from elementary.messages.messaging_integrations.base_messaging_integration import (
BaseMessagingIntegration,
MessageSendResult,
)
from elementary.messages.messaging_integrations.exceptions import (
MessagingIntegrationError,
)
from elementary.tracking.tracking_interface import Tracking
from elementary.utils.log import get_logger

logger = get_logger(__name__)

ONE_MINUTE = 60
ONE_SECOND = 1


Channel: TypeAlias = str


class SlackWebMessageContext(BaseModel):
id: str
channel: Channel


class SlackWebMessagingIntegration(
BaseMessagingIntegration[Channel, SlackWebMessageContext]
):
def __init__(self, client: WebClient, tracking: Optional[Tracking] = None) -> None:
self.client = client
self.tracking = tracking
self._email_to_user_id_cache: Dict[str, str] = {}

@classmethod
def from_token(
cls, token: str, tracking: Optional[Tracking] = None
) -> "SlackWebMessagingIntegration":
client = WebClient(token=token)
client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=5))
return cls(client, tracking)

def supports_reply(self) -> bool:
return True

def send_message(
self, destination: Channel, body: MessageBody
) -> MessageSendResult[SlackWebMessageContext]:
formatted_message = format_block_kit(body, self.get_user_id_from_email)
return self._send_message(destination, formatted_message)

def reply_to_message(
self,
destination: Channel,
message_context: SlackWebMessageContext,
body: MessageBody,
) -> MessageSendResult[SlackWebMessageContext]:
formatted_message = format_block_kit(body, self.get_user_id_from_email)
return self._send_message(
destination, formatted_message, thread_ts=message_context.id
)

@sleep_and_retry
@limits(calls=1, period=ONE_SECOND)
def _send_message(
self,
destination: Channel,
formatted_message: FormattedBlockKitMessage,
thread_ts: Optional[str] = None,
) -> MessageSendResult[SlackWebMessageContext]:
try:
response = self.client.chat_postMessage(
channel=destination,
blocks=json.dumps(formatted_message["blocks"]),
attachments=json.dumps(formatted_message["attachments"]),
thread_ts=thread_ts,
)
except SlackApiError as e:
self._handle_send_err(e, destination)
return self._send_message(destination, body, thread_ts)

return MessageSendResult(
message_context=SlackWebMessageContext(
id=response["ts"], channel=response["channel"]
),
timestamp=response["ts"],
)

def _handle_send_err(self, err: SlackApiError, channel_name: str) -> bool:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

from this point down, mainly adapted from the existing clients, with some tweaks

if self.tracking:
self.tracking.record_internal_exception(err)
err_type = err.response.data["error"]
if err_type == "not_in_channel":
channel_id = self._get_channel_id(channel_name)
self._join_channel(channel_id=channel_id)
elif err_type == "channel_not_found":
raise MessagingIntegrationError(
f"Channel {channel_name} was not found by the Elementary app. Please add the app to the channel."
)
raise MessagingIntegrationError(
f"Failed to send a message to channel - {channel_name}"
)

@sleep_and_retry
@limits(calls=20, period=ONE_MINUTE)
def _iter_channels(self, cursor: Optional[str] = None) -> Iterator[dict]:
response = self.client.conversations_list(
cursor=cursor,
types="public_channel,private_channel",
exclude_archived=True,
limit=1000,
)
channels = response["channels"]
yield from channels
response_metadata = response.get("response_metadata") or {}
next_cursor = response_metadata.get("next_cursor")
if next_cursor:
if not isinstance(next_cursor, str):
raise ValueError("Next cursor is not a string")
yield from self._iter_channels(next_cursor)

def _get_channel_id(self, channel_name: str) -> str:
for channel in self._iter_channels():
if channel["name"] == channel_name:
return channel["id"]
raise MessagingIntegrationError(f"Channel {channel_name} not found")

def _join_channel(self, channel_id: str) -> None:
try:
self.client.conversations_join(channel=channel_id)
except SlackApiError as e:
if self.tracking:
self.tracking.record_internal_exception(e)
raise MessagingIntegrationError(f"Failed to join channel {channel_id}")

@sleep_and_retry
@limits(calls=50, period=ONE_MINUTE)
def get_user_id_from_email(self, email: str) -> Optional[str]:
if email in self._email_to_user_id_cache:
return self._email_to_user_id_cache[email]
try:
user_id = self.client.users_lookupByEmail(email=email)["user"]["id"]
self._email_to_user_id_cache[email] = user_id
return user_id
except SlackApiError as err:
if err.response.data["error"] != "users_not_found":
logger.error(f"Unable to get Slack user ID from email: {err}.")
return None
Loading
Loading