Skip to content
Merged
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
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 Any, Dict, List, Optional, Union
from typing import Any, Dict, 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,
TableBlock,
TextBlock,
TextStyle,
Expand Down Expand Up @@ -47,6 +49,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
53 changes: 37 additions & 16 deletions elementary/messages/formats/block_kit.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Callable, List, Optional, Tuple

from pydantic import BaseModel
from slack_sdk.models import blocks as slack_blocks
from tabulate import tabulate

Expand All @@ -14,9 +15,11 @@
Icon,
IconBlock,
InlineBlock,
InlineCodeBlock,
LineBlock,
LinesBlock,
LinkBlock,
MentionBlock,
TableBlock,
TextBlock,
TextStyle,
Expand All @@ -31,15 +34,26 @@
}


class FormattedBlockKitMessage(BaseModel):
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 (```)
_MAX_CELL_LENGTH_BY_COLUMN_COUNT = {4: 11, 3: 14, 2: 22, 1: 40, 0: 40}

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 @@ -59,6 +73,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 @@ -192,12 +216,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 @@ -239,25 +257,28 @@ 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)
color_code = COLOR_MAP.get(message.color) if message.color else None
blocks, attachment_blocks = self._get_final_blocks(message.color)
built_message = {
"blocks": blocks,
"attachments": [
built_message = FormattedBlockKitMessage(
blocks=blocks,
attachments=[
{
"blocks": attachment_blocks,
}
],
}
)
if color_code:
built_message["attachments"][0]["color"] = color_code
for attachment in built_message.attachments:
attachment["color"] = color_code
return 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)
22 changes: 5 additions & 17 deletions elementary/messages/messaging_integrations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ If your platform's message format is not yet supported:
- LinesBlock: Plain text content
- FactListBlock: Key-value pairs
- ExpandableBlock: Collapsible sections
- MentionBlock: Mention a user
- TableBlock: Table of data
```
3. Add tests in `tests/unit/messages/formats/`

Expand Down Expand Up @@ -92,19 +94,6 @@ Once the message format is ready:
- Make sure to get all required information from users to create your destination type
- See Teams implementation for reference (webhook URL configuration)

## Migration Strategy

The system currently supports both:

- Legacy `BaseIntegration` implementations (e.g., Slack)
- New `BaseMessagingIntegration` implementations (e.g., Teams)

This dual support allows for a gradual migration path where:

1. New integrations are implemented using `BaseMessagingIntegration`
2. Existing integrations can be migrated one at a time
3. The legacy `BaseIntegration` will eventually be deprecated

## Implementing a New Integration

To add a new messaging platform integration:
Expand All @@ -120,10 +109,9 @@ To add a new messaging platform integration:

## Current Implementations

- **Teams**: Uses the new `BaseMessagingIntegration` system with webhook support and Adaptive Cards format
- **Slack**: Currently uses the legacy `BaseIntegration` system (planned for migration)
- **Teams**: Webhook support, Adaptive Cards format
- **Slack**: Webhook and token support, Block Kit format

## Future Improvements

1. Complete migration of Slack to `BaseMessagingIntegration`
2. Add support for more messaging platforms
1. Add support for more messaging platforms
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")
Loading
Loading