Skip to content

Commit 58541e0

Browse files
committed
Add adaptive cards message formatting and unit tests
- Introduced new adaptive cards message formatting in , including functions to format various message blocks such as headers, lines, facts, and expandable sections. - Created a new function to generate adaptive card structures based on message body input. - Added comprehensive unit tests in to validate formatting functionality, covering scenarios like colored headers, text styles, bullet lists, and expandable blocks. - Included expected output JSON files for various test cases to ensure accurate formatting results.
1 parent 6f239c4 commit 58541e0

18 files changed

+1178
-0
lines changed

elementary/messages/formats/__init__.py

Whitespace-only changes.
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import uuid
2+
from typing import Any, Dict, List, Optional
3+
4+
from elementary.messages.blocks import (
5+
CodeBlock,
6+
DividerBlock,
7+
ExpandableBlock,
8+
FactListBlock,
9+
HeaderBlock,
10+
Icon,
11+
IconBlock,
12+
InlineBlock,
13+
LineBlock,
14+
LinesBlock,
15+
LinkBlock,
16+
MessageBlock,
17+
TextBlock,
18+
TextStyle,
19+
)
20+
from elementary.messages.message_body import Color, MessageBody
21+
22+
ICON_TO_HTML = {
23+
Icon.RED_TRIANGLE: "🔺",
24+
Icon.X: "❌",
25+
Icon.WARNING: "⚠",
26+
Icon.EXCLAMATION: "❗",
27+
Icon.CHECK: "✅",
28+
Icon.MAGNIFYING_GLASS: "🔍",
29+
Icon.HAMMER_AND_WRENCH: "🛠",
30+
}
31+
32+
COLOR_TO_STYLE = {
33+
Color.RED: "Attention",
34+
Color.YELLOW: "Warning",
35+
Color.GREEN: "Good",
36+
}
37+
38+
39+
def format_icon(icon: Icon) -> str:
40+
return ICON_TO_HTML[icon]
41+
42+
43+
def format_text_block(block: TextBlock) -> str:
44+
if block.style == TextStyle.BOLD:
45+
return f"**{block.text}**"
46+
elif block.style == TextStyle.ITALIC:
47+
return f"_{block.text}_"
48+
else:
49+
return block.text
50+
51+
52+
def format_inline_block(block: InlineBlock) -> str:
53+
if isinstance(block, IconBlock):
54+
return format_icon(block.icon)
55+
elif isinstance(block, TextBlock):
56+
return format_text_block(block)
57+
elif isinstance(block, LinkBlock):
58+
return f"[{block.text}]({block.url})"
59+
else:
60+
raise ValueError(f"Unsupported inline block type: {type(block)}")
61+
62+
63+
def format_line_block_text(block: LineBlock) -> str:
64+
return block.sep.join([format_inline_block(inline) for inline in block.inlines])
65+
66+
67+
def format_line_block(block: LineBlock) -> Dict[str, Any]:
68+
text = format_line_block_text(block)
69+
70+
return {
71+
"type": "TextBlock",
72+
"text": text,
73+
"wrap": True,
74+
}
75+
76+
77+
def format_lines_block(block: LinesBlock) -> List[Dict[str, Any]]:
78+
return [format_line_block(line_block) for line_block in block.lines]
79+
80+
81+
def format_header_block(
82+
block: HeaderBlock, color: Optional[Color] = None
83+
) -> Dict[str, Any]:
84+
return {
85+
"type": "Container",
86+
"items": [
87+
{
88+
"type": "TextBlock",
89+
"text": block.text,
90+
"weight": "bolder",
91+
"size": "large",
92+
}
93+
],
94+
"style": COLOR_TO_STYLE[color] if color else "Default",
95+
}
96+
97+
98+
def format_code_block(block: CodeBlock) -> Dict[str, Any]:
99+
return {
100+
"type": "Container",
101+
"style": "emphasis",
102+
"items": [
103+
{
104+
"type": "TextBlock",
105+
"text": f"```{block.text}```",
106+
"fontType": "monospace",
107+
}
108+
],
109+
}
110+
111+
112+
def format_fact_list_block(block: FactListBlock) -> Dict[str, Any]:
113+
return {
114+
"type": "FactSet",
115+
"facts": [
116+
{
117+
"title": format_line_block_text(fact.title),
118+
"value": format_line_block_text(fact.value),
119+
}
120+
for fact in block.facts
121+
],
122+
}
123+
124+
125+
def format_message_block(
126+
block: MessageBlock, color: Optional[Color] = None
127+
) -> List[Dict[str, Any]]:
128+
if isinstance(block, HeaderBlock):
129+
return [format_header_block(block, color)]
130+
elif isinstance(block, CodeBlock):
131+
return [format_code_block(block)]
132+
elif isinstance(block, LinesBlock):
133+
return format_lines_block(block)
134+
elif isinstance(block, FactListBlock):
135+
return [format_fact_list_block(block)]
136+
elif isinstance(block, ExpandableBlock):
137+
return format_expandable_block(block)
138+
else:
139+
raise ValueError(f"Unsupported message block type: {type(block)}")
140+
141+
142+
def split_message_blocks_by_divider(
143+
blocks: List[MessageBlock],
144+
) -> List[List[MessageBlock]]:
145+
first_divider_index = next(
146+
(i for i, block in enumerate(blocks) if isinstance(block, DividerBlock)),
147+
None,
148+
)
149+
if first_divider_index is None:
150+
return [blocks] if blocks else []
151+
return [
152+
blocks[:first_divider_index],
153+
*split_message_blocks_by_divider(blocks[first_divider_index + 1 :]),
154+
]
155+
156+
157+
def format_divided_message_blocks(
158+
blocks: List[MessageBlock],
159+
divider: bool = False,
160+
color: Optional[Color] = None,
161+
) -> Dict[str, Any]:
162+
return {
163+
"type": "Container",
164+
"separator": divider,
165+
"items": [
166+
item for block in blocks for item in format_message_block(block, color)
167+
],
168+
}
169+
170+
171+
def format_expandable_block(block: ExpandableBlock) -> List[Dict[str, Any]]:
172+
block_title = block.title
173+
expandable_target_id = f"expandable-{uuid.uuid4()}"
174+
return [
175+
{
176+
"type": "ActionSet",
177+
"actions": [
178+
{
179+
"type": "Action.ToggleVisibility",
180+
"title": block_title,
181+
"targetElements": [expandable_target_id],
182+
}
183+
],
184+
},
185+
{
186+
"type": "Container",
187+
"id": expandable_target_id,
188+
"items": format_message_blocks(block.body),
189+
"isVisible": block.expanded,
190+
},
191+
]
192+
193+
194+
def format_message_blocks(
195+
blocks: List[MessageBlock], color: Optional[Color] = None
196+
) -> List[Dict[str, Any]]:
197+
if not blocks:
198+
return []
199+
200+
message_blocks = split_message_blocks_by_divider(blocks)
201+
# The divider is not a block in adaptive cards, it's a property of the container.
202+
return [
203+
format_divided_message_blocks(blocks, divider=True, color=color)
204+
for blocks in message_blocks
205+
]
206+
207+
208+
def format_message_body(message: MessageBody) -> List[Dict[str, Any]]:
209+
return format_message_blocks(message.blocks, message.color)
210+
211+
212+
def format(message: MessageBody, version: str = "1.6") -> Dict[str, Any]:
213+
if version < "1.2" or version > "1.6":
214+
raise ValueError(f"Version {version} is not supported")
215+
return {
216+
"type": "AdaptiveCard",
217+
"body": format_message_body(message),
218+
"version": version,
219+
}

tests/unit/messages/__init__.py

Whitespace-only changes.

tests/unit/messages/formats/__init__.py

Whitespace-only changes.

tests/unit/messages/formats/adaptive_cards/__init__.py

Whitespace-only changes.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
{
2+
"type": "AdaptiveCard",
3+
"version": "1.6",
4+
"body": [
5+
{
6+
"type": "Container",
7+
"separator": true,
8+
"items": [
9+
{
10+
"type": "Container",
11+
"items": [
12+
{
13+
"type": "TextBlock",
14+
"text": "Main Header",
15+
"weight": "bolder",
16+
"size": "large"
17+
}
18+
],
19+
"style": "Good"
20+
},
21+
{
22+
"type": "TextBlock",
23+
"text": "Normal text **Bold text** _Italic text_",
24+
"wrap": true
25+
},
26+
{
27+
"type": "TextBlock",
28+
"text": "- First bullet point",
29+
"wrap": true
30+
},
31+
{
32+
"type": "TextBlock",
33+
"text": "- Second bullet point",
34+
"wrap": true
35+
},
36+
{
37+
"type": "TextBlock",
38+
"text": "&#x2705; Check item",
39+
"wrap": true
40+
},
41+
{
42+
"type": "FactSet",
43+
"facts": [
44+
{
45+
"title": "Status",
46+
"value": "Passed"
47+
},
48+
{
49+
"title": "Tags",
50+
"value": "test, example"
51+
}
52+
]
53+
},
54+
{
55+
"type": "ActionSet",
56+
"actions": [
57+
{
58+
"type": "Action.ToggleVisibility",
59+
"title": "Show Details",
60+
"targetElements": [
61+
"expandable-00000000-0000-0000-0000-000000000001"
62+
]
63+
}
64+
]
65+
},
66+
{
67+
"type": "Container",
68+
"id": "expandable-00000000-0000-0000-0000-000000000001",
69+
"items": [
70+
{
71+
"type": "Container",
72+
"separator": true,
73+
"items": [
74+
{
75+
"type": "TextBlock",
76+
"text": "&#x1F50D; **Details Section**",
77+
"wrap": true
78+
},
79+
{
80+
"type": "TextBlock",
81+
"text": "Here's some content with a [link](https://example.com)",
82+
"wrap": true
83+
}
84+
]
85+
}
86+
],
87+
"isVisible": false
88+
}
89+
]
90+
}
91+
]
92+
}

0 commit comments

Comments
 (0)