Skip to content

Commit cca5481

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 a010b08 commit cca5481

18 files changed

+1185
-0
lines changed

elementary/messages/formats/__init__.py

Whitespace-only changes.
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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+
"wrap": True,
93+
}
94+
],
95+
"style": COLOR_TO_STYLE[color] if color else "Default",
96+
}
97+
98+
99+
def format_code_block(block: CodeBlock) -> Dict[str, Any]:
100+
return {
101+
"type": "Container",
102+
"style": "emphasis",
103+
"items": [
104+
{
105+
"type": "TextBlock",
106+
"text": f"```{block.text}```",
107+
"fontType": "monospace",
108+
}
109+
],
110+
}
111+
112+
113+
def format_fact_list_block(block: FactListBlock) -> Dict[str, Any]:
114+
return {
115+
"type": "FactSet",
116+
"facts": [
117+
{
118+
"title": format_line_block_text(fact.title),
119+
"value": format_line_block_text(fact.value),
120+
}
121+
for fact in block.facts
122+
],
123+
}
124+
125+
126+
def format_message_block(
127+
block: MessageBlock, color: Optional[Color] = None
128+
) -> List[Dict[str, Any]]:
129+
if isinstance(block, HeaderBlock):
130+
return [format_header_block(block, color)]
131+
elif isinstance(block, CodeBlock):
132+
return [format_code_block(block)]
133+
elif isinstance(block, LinesBlock):
134+
return format_lines_block(block)
135+
elif isinstance(block, FactListBlock):
136+
return [format_fact_list_block(block)]
137+
elif isinstance(block, ExpandableBlock):
138+
return format_expandable_block(block)
139+
else:
140+
raise ValueError(f"Unsupported message block type: {type(block)}")
141+
142+
143+
def split_message_blocks_by_divider(
144+
blocks: List[MessageBlock],
145+
) -> List[List[MessageBlock]]:
146+
first_divider_index = next(
147+
(i for i, block in enumerate(blocks) if isinstance(block, DividerBlock)),
148+
None,
149+
)
150+
if first_divider_index is None:
151+
return [blocks] if blocks else []
152+
return [
153+
blocks[:first_divider_index],
154+
*split_message_blocks_by_divider(blocks[first_divider_index + 1 :]),
155+
]
156+
157+
158+
def format_divided_message_blocks(
159+
blocks: List[MessageBlock],
160+
divider: bool = False,
161+
color: Optional[Color] = None,
162+
) -> Dict[str, Any]:
163+
return {
164+
"type": "Container",
165+
"separator": divider,
166+
"items": [
167+
item for block in blocks for item in format_message_block(block, color)
168+
],
169+
}
170+
171+
172+
def format_expandable_block(block: ExpandableBlock) -> List[Dict[str, Any]]:
173+
block_title = block.title
174+
expandable_target_id = f"expandable-{uuid.uuid4()}"
175+
return [
176+
{
177+
"type": "ActionSet",
178+
"actions": [
179+
{
180+
"type": "Action.ToggleVisibility",
181+
"title": block_title,
182+
"targetElements": [expandable_target_id],
183+
}
184+
],
185+
},
186+
{
187+
"type": "Container",
188+
"id": expandable_target_id,
189+
"items": format_message_blocks(block.body),
190+
"isVisible": block.expanded,
191+
},
192+
]
193+
194+
195+
def format_message_blocks(
196+
blocks: List[MessageBlock], color: Optional[Color] = None
197+
) -> List[Dict[str, Any]]:
198+
if not blocks:
199+
return []
200+
201+
message_blocks = split_message_blocks_by_divider(blocks)
202+
# The divider is not a block in adaptive cards, it's a property of the container.
203+
return [
204+
format_divided_message_blocks(blocks, divider=True, color=color)
205+
for blocks in message_blocks
206+
]
207+
208+
209+
def format_message_body(message: MessageBody) -> List[Dict[str, Any]]:
210+
return format_message_blocks(message.blocks, message.color)
211+
212+
213+
def format(message: MessageBody, version: str = "1.6") -> Dict[str, Any]:
214+
if version < "1.2" or version > "1.6":
215+
raise ValueError(f"Version {version} is not supported")
216+
return {
217+
"type": "AdaptiveCard",
218+
"body": format_message_body(message),
219+
"version": version,
220+
}

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

0 commit comments

Comments
 (0)