-
Notifications
You must be signed in to change notification settings - Fork 208
Ele 4067 slack messaging integration #1822
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
d718193
f3754c7
eb8f93f
5ac6acd
3dafa93
bfa84ca
4e6b70f
f17006f
aef974e
a2db2ab
82245a7
a7a38ca
50bc881
46eef40
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
@@ -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() | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
|
|
@@ -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] | ||
|
|
||
|
|
||
| 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 | ||
|
|
||
|
|
@@ -12,9 +12,11 @@ | |
| Icon, | ||
| IconBlock, | ||
| InlineBlock, | ||
| InlineCodeBlock, | ||
| LineBlock, | ||
| LinesBlock, | ||
| LinkBlock, | ||
| MentionBlock, | ||
| TextBlock, | ||
| TextStyle, | ||
| ) | ||
|
|
@@ -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] | ||
|
|
@@ -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)}") | ||
|
|
||
|
|
@@ -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( | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
|
@@ -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) | ||
|
|
@@ -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 |
|---|---|---|
| @@ -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: | ||
|
||
| 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) | ||
MikaKerman marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
Uh oh!
There was an error while loading. Please reload this page.