Skip to content

Commit d81c220

Browse files
feat(np): Add custom blocks for formatting and styling to BE (#102975)
1 parent 66fd42e commit d81c220

File tree

11 files changed

+623
-48
lines changed

11 files changed

+623
-48
lines changed

src/sentry/integrations/msteams/card_builder/block.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ class ColumnBlock(_ColumnBlockNotRequired):
120120
width: ColumnWidth | str
121121

122122

123+
class CodeBlock(TypedDict):
124+
type: Literal["CodeBlock"]
125+
codeSnippet: str
126+
127+
123128
class ColumnSetBlock(TypedDict):
124129
type: Literal["ColumnSet"]
125130
columns: list[ColumnBlock]
@@ -147,7 +152,13 @@ class InputChoiceSetBlock(_InputChoiceSetBlockNotRequired):
147152

148153
ItemBlock: TypeAlias = str | TextBlock | ImageBlock
149154
Block: TypeAlias = (
150-
ActionSet | TextBlock | ImageBlock | ColumnSetBlock | ContainerBlock | InputChoiceSetBlock
155+
ActionSet
156+
| TextBlock
157+
| ImageBlock
158+
| ColumnSetBlock
159+
| ContainerBlock
160+
| InputChoiceSetBlock
161+
| CodeBlock
151162
)
152163

153164

@@ -173,6 +184,13 @@ def create_text_block(text: str | None, **kwargs: Unpack[_TextBlockNotRequired])
173184
}
174185

175186

187+
def create_code_block(text: str) -> CodeBlock:
188+
return {
189+
"type": "CodeBlock",
190+
"codeSnippet": escape_markdown_special_chars(text),
191+
}
192+
193+
176194
def create_logo_block(**kwargs: Unpack[_ImageBlockNotRequired]) -> ImageBlock:
177195
# Default size if no size is given
178196
if not kwargs.get("height"):

src/sentry/notifications/platform/api/endpoints/internal_registered_templates.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,16 @@ def get(self, request: Request) -> Response:
4040
def serialize_rendered_example(rendered_template: NotificationRenderedTemplate) -> dict[str, Any]:
4141
response: dict[str, Any] = {
4242
"subject": rendered_template.subject,
43-
"body": rendered_template.body,
43+
"body": [
44+
{
45+
"type": block.type,
46+
"blocks": [
47+
{"type": text_block.type, "text": text_block.text}
48+
for text_block in block.blocks
49+
],
50+
}
51+
for block in rendered_template.body
52+
],
4453
"actions": [
4554
{"label": action.label, "link": action.link} for action in rendered_template.actions
4655
],

src/sentry/notifications/platform/discord/provider.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from typing import TYPE_CHECKING
24

35
from sentry.notifications.platform.provider import NotificationProvider, NotificationProviderError
@@ -8,6 +10,10 @@
810
PreparedIntegrationNotificationTarget,
911
)
1012
from sentry.notifications.platform.types import (
13+
NotificationBodyFormattingBlock,
14+
NotificationBodyFormattingBlockType,
15+
NotificationBodyTextBlock,
16+
NotificationBodyTextBlockType,
1117
NotificationData,
1218
NotificationProviderKey,
1319
NotificationRenderedTemplate,
@@ -18,6 +24,9 @@
1824

1925
if TYPE_CHECKING:
2026
from sentry.integrations.discord.message_builder.base.base import DiscordMessage
27+
from sentry.integrations.discord.message_builder.base.embed.field import (
28+
DiscordMessageEmbedField,
29+
)
2130

2231
# TODO(ecosystem): Proper typing - https://discord.com/developers/docs/resources/message#create-message
2332
type DiscordRenderable = DiscordMessage
@@ -51,10 +60,12 @@ def render[DataT: NotificationData](
5160
components: list[DiscordMessageComponent] = []
5261
embeds = []
5362

63+
body_blocks = cls.render_body_blocks(rendered_template.body)
64+
5465
embeds.append(
5566
DiscordMessageEmbed(
5667
title=rendered_template.subject,
57-
description=rendered_template.body,
68+
fields=body_blocks,
5869
image=(
5970
DiscordMessageEmbedImage(url=rendered_template.chart.url)
6071
if rendered_template.chart
@@ -82,6 +93,42 @@ def render[DataT: NotificationData](
8293

8394
return builder.build()
8495

96+
@classmethod
97+
def render_body_blocks(
98+
cls, body: list[NotificationBodyFormattingBlock]
99+
) -> list[DiscordMessageEmbedField]:
100+
from sentry.integrations.discord.message_builder.base.embed.field import (
101+
DiscordMessageEmbedField,
102+
)
103+
104+
fields = []
105+
for block in body:
106+
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
107+
fields.append(
108+
DiscordMessageEmbedField(
109+
name=block.type.value, value=cls.render_text_blocks(block.blocks)
110+
)
111+
)
112+
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
113+
fields.append(
114+
DiscordMessageEmbedField(
115+
name=block.type.value, value=f"```{cls.render_text_blocks(block.blocks)}```"
116+
)
117+
)
118+
return fields
119+
120+
@classmethod
121+
def render_text_blocks(cls, blocks: list[NotificationBodyTextBlock]) -> str:
122+
texts = []
123+
for block in blocks:
124+
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
125+
texts.append(block.text)
126+
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
127+
texts.append(f"**{block.text}**")
128+
elif block.type == NotificationBodyTextBlockType.CODE:
129+
texts.append(f"`{block.text}`")
130+
return " ".join(texts)
131+
85132

86133
@provider_registry.register(NotificationProviderKey.DISCORD)
87134
class DiscordNotificationProvider(NotificationProvider[DiscordRenderable]):

src/sentry/notifications/platform/email/provider.py

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
from django.core.mail import EmailMultiAlternatives
22
from django.core.mail.message import make_msgid
3+
from django.utils.html import escape
4+
from django.utils.safestring import mark_safe
35

46
from sentry import options
57
from sentry.notifications.platform.provider import NotificationProvider
68
from sentry.notifications.platform.registry import provider_registry
79
from sentry.notifications.platform.renderer import NotificationRenderer
810
from sentry.notifications.platform.target import GenericNotificationTarget
911
from sentry.notifications.platform.types import (
12+
NotificationBodyFormattingBlock,
13+
NotificationBodyFormattingBlockType,
14+
NotificationBodyTextBlock,
15+
NotificationBodyTextBlockType,
1016
NotificationData,
1117
NotificationProviderKey,
1218
NotificationRenderedTemplate,
@@ -32,24 +38,29 @@ class EmailRenderer(NotificationRenderer[EmailRenderable]):
3238
def render[DataT: NotificationData](
3339
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
3440
) -> EmailRenderable:
41+
html_body_blocks = cls.render_body_blocks_to_html_string(rendered_template.body)
42+
txt_body_blocks = cls.render_body_blocks_to_txt_string(rendered_template.body)
43+
3544
email_context = {
3645
"subject": rendered_template.subject,
37-
"body": rendered_template.body,
3846
"actions": [(action.label, action.link) for action in rendered_template.actions],
3947
"chart_url": rendered_template.chart.url if rendered_template.chart else None,
4048
"chart_alt_text": rendered_template.chart.alt_text if rendered_template.chart else None,
4149
"footer": rendered_template.footer,
4250
}
4351

52+
html_email_context = {**email_context, "body": html_body_blocks}
53+
txt_email_context = {**email_context, "body": txt_body_blocks}
54+
4455
html_body = inline_css(
4556
render_to_string(
4657
template=(rendered_template.email_html_path or DEFAULT_EMAIL_HTML_PATH),
47-
context=email_context,
58+
context=html_email_context,
4859
)
4960
)
5061
txt_body = render_to_string(
5162
template=(rendered_template.email_text_path or DEFAULT_EMAIL_TEXT_PATH),
52-
context=email_context,
63+
context=txt_email_context,
5364
)
5465
# Required by RFC 2822 (https://www.rfc-editor.org/rfc/rfc2822.html)
5566
headers = {"Message-Id": make_msgid(domain=get_from_email_domain())}
@@ -68,6 +79,58 @@ def render[DataT: NotificationData](
6879
email.attach_alternative(html_body, "text/html")
6980
return email
7081

82+
@classmethod
83+
def render_body_blocks_to_html_string(cls, body: list[NotificationBodyFormattingBlock]) -> str:
84+
body_blocks = []
85+
for block in body:
86+
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
87+
safe_content = cls.render_text_blocks_to_html_string(block.blocks)
88+
body_blocks.append(f"<p>{safe_content}</p>")
89+
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
90+
safe_content = cls.render_text_blocks_to_html_string(block.blocks)
91+
body_blocks.append(f"<pre><code>{safe_content}</code></pre>")
92+
93+
return mark_safe("".join(body_blocks))
94+
95+
@classmethod
96+
def render_text_blocks_to_html_string(cls, blocks: list[NotificationBodyTextBlock]) -> str:
97+
texts: list[str] = []
98+
for block in blocks:
99+
# Escape user content to prevent XSS
100+
escaped_text = escape(block.text)
101+
102+
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
103+
texts.append(escaped_text)
104+
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
105+
# HTML tags are safe, content is escaped
106+
texts.append(f"<strong>{escaped_text}</strong>")
107+
elif block.type == NotificationBodyTextBlockType.CODE:
108+
texts.append(f"<code>{escaped_text}</code>")
109+
110+
return " ".join(texts)
111+
112+
@classmethod
113+
def render_body_blocks_to_txt_string(cls, blocks: list[NotificationBodyFormattingBlock]) -> str:
114+
body_blocks = []
115+
for block in blocks:
116+
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
117+
body_blocks.append(f"\n{cls.render_text_blocks_to_txt_string(block.blocks)}")
118+
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
119+
body_blocks.append(f"\n```{cls.render_text_blocks_to_txt_string(block.blocks)}```")
120+
return " ".join(body_blocks)
121+
122+
@classmethod
123+
def render_text_blocks_to_txt_string(cls, blocks: list[NotificationBodyTextBlock]) -> str:
124+
texts = []
125+
for block in blocks:
126+
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
127+
texts.append(block.text)
128+
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
129+
texts.append(f"**{block.text}**")
130+
elif block.type == NotificationBodyTextBlockType.CODE:
131+
texts.append(f"`{block.text}`")
132+
return " ".join(texts)
133+
71134

72135
@provider_registry.register(NotificationProviderKey.EMAIL)
73136
class EmailNotificationProvider(NotificationProvider[EmailRenderable]):

src/sentry/notifications/platform/msteams/provider.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
from typing import TYPE_CHECKING
24

35
from sentry.notifications.platform.provider import NotificationProvider, NotificationProviderError
@@ -8,6 +10,10 @@
810
PreparedIntegrationNotificationTarget,
911
)
1012
from sentry.notifications.platform.types import (
13+
NotificationBodyFormattingBlock,
14+
NotificationBodyFormattingBlockType,
15+
NotificationBodyTextBlock,
16+
NotificationBodyTextBlockType,
1117
NotificationData,
1218
NotificationProviderKey,
1319
NotificationRenderedTemplate,
@@ -17,7 +23,7 @@
1723
from sentry.organizations.services.organization.model import RpcOrganizationSummary
1824

1925
if TYPE_CHECKING:
20-
from sentry.integrations.msteams.card_builder.block import AdaptiveCard
26+
from sentry.integrations.msteams.card_builder.block import AdaptiveCard, Block
2127

2228
type MSTeamsRenderable = AdaptiveCard
2329

@@ -35,8 +41,6 @@ def render[DataT: NotificationData](
3541
Action,
3642
ActionSet,
3743
ActionType,
38-
AdaptiveCard,
39-
Block,
4044
ImageBlock,
4145
OpenUrlAction,
4246
TextSize,
@@ -47,9 +51,8 @@ def render[DataT: NotificationData](
4751
title_text = create_text_block(
4852
text=rendered_template.subject, size=TextSize.LARGE, weight=TextWeight.BOLDER
4953
)
50-
body_text = create_text_block(text=rendered_template.body)
51-
52-
body_blocks: list[Block] = [title_text, body_text]
54+
body_text = cls.render_body_blocks(rendered_template.body)
55+
body_blocks: list[Block] = [title_text, *body_text]
5356

5457
if len(rendered_template.actions) > 0:
5558
actions: list[Action] = []
@@ -81,6 +84,33 @@ def render[DataT: NotificationData](
8184
}
8285
return card
8386

87+
@classmethod
88+
def render_body_blocks(cls, body: list[NotificationBodyFormattingBlock]) -> list[Block]:
89+
from sentry.integrations.msteams.card_builder.block import (
90+
create_code_block,
91+
create_text_block,
92+
)
93+
94+
body_blocks: list[Block] = []
95+
for block in body:
96+
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
97+
body_blocks.append(create_text_block(text=cls.render_text_blocks(block.blocks)))
98+
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
99+
body_blocks.append(create_code_block(text=cls.render_text_blocks(block.blocks)))
100+
return body_blocks
101+
102+
@classmethod
103+
def render_text_blocks(cls, blocks: list[NotificationBodyTextBlock]) -> str:
104+
texts = []
105+
for block in blocks:
106+
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
107+
texts.append(block.text)
108+
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
109+
texts.append(f"**{block.text}**")
110+
elif block.type == NotificationBodyTextBlockType.CODE:
111+
texts.append(f"`{block.text}`")
112+
return " ".join(texts)
113+
84114

85115
@provider_registry.register(NotificationProviderKey.MSTEAMS)
86116
class MSTeamsNotificationProvider(NotificationProvider[MSTeamsRenderable]):

src/sentry/notifications/platform/slack/provider.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
PreparedIntegrationNotificationTarget,
2121
)
2222
from sentry.notifications.platform.types import (
23+
NotificationBodyFormattingBlock,
24+
NotificationBodyFormattingBlockType,
25+
NotificationBodyTextBlock,
26+
NotificationBodyTextBlockType,
2327
NotificationData,
2428
NotificationProviderKey,
2529
NotificationRenderedTemplate,
@@ -42,9 +46,9 @@ def render[DataT: NotificationData](
4246
cls, *, data: DataT, rendered_template: NotificationRenderedTemplate
4347
) -> SlackRenderable:
4448
subject = HeaderBlock(text=PlainTextObject(text=rendered_template.subject))
45-
body = SectionBlock(text=MarkdownTextObject(text=rendered_template.body))
49+
body_blocks: list[Block] = cls._render_body(rendered_template.body)
4650

47-
blocks = [subject, body]
51+
blocks = [subject, *body_blocks]
4852

4953
if len(rendered_template.actions) > 0:
5054
actions_block = ActionsBlock(elements=[])
@@ -63,6 +67,30 @@ def render[DataT: NotificationData](
6367

6468
return SlackRenderable(blocks=blocks, text=rendered_template.subject)
6569

70+
@classmethod
71+
def _render_body(cls, body: list[NotificationBodyFormattingBlock]) -> list[Block]:
72+
blocks: list[Block] = []
73+
for block in body:
74+
if block.type == NotificationBodyFormattingBlockType.PARAGRAPH:
75+
text = cls._render_text_blocks(block.blocks)
76+
blocks.append(SectionBlock(text=MarkdownTextObject(text=text)))
77+
elif block.type == NotificationBodyFormattingBlockType.CODE_BLOCK:
78+
text = cls._render_text_blocks(block.blocks)
79+
blocks.append(SectionBlock(text=MarkdownTextObject(text=f"```{text}```")))
80+
return blocks
81+
82+
@classmethod
83+
def _render_text_blocks(cls, blocks: list[NotificationBodyTextBlock]) -> str:
84+
texts = []
85+
for block in blocks:
86+
if block.type == NotificationBodyTextBlockType.PLAIN_TEXT:
87+
texts.append(block.text)
88+
elif block.type == NotificationBodyTextBlockType.BOLD_TEXT:
89+
texts.append(f"*{block.text}*")
90+
elif block.type == NotificationBodyTextBlockType.CODE:
91+
texts.append(f"`{block.text}`")
92+
return " ".join(texts)
93+
6694

6795
@provider_registry.register(NotificationProviderKey.SLACK)
6896
class SlackNotificationProvider(NotificationProvider[SlackRenderable]):

0 commit comments

Comments
 (0)