Openstatus
www.openstatus.dev
1import type { FormattedMessageData } from "@openstatus/notification-base";
2
3/**
4 * Slack Block types for rich message formatting
5 * Reference: https://docs.slack.dev/messaging/formatting-message-text
6 */
7
8interface SlackTextObject {
9 type: "plain_text" | "mrkdwn";
10 text: string;
11 emoji?: boolean;
12}
13
14interface SlackHeaderBlock {
15 type: "header";
16 text: SlackTextObject;
17}
18
19interface SlackSectionBlock {
20 type: "section";
21 text?: SlackTextObject;
22 fields?: SlackTextObject[];
23 accessory?: SlackButtonElement;
24}
25
26interface SlackDividerBlock {
27 type: "divider";
28}
29
30interface SlackActionsBlock {
31 type: "actions";
32 elements: SlackButtonElement[];
33}
34
35interface SlackButtonElement {
36 type: "button";
37 text: SlackTextObject;
38 url?: string;
39 action_id?: string;
40}
41
42type SlackBlock =
43 | SlackHeaderBlock
44 | SlackSectionBlock
45 | SlackDividerBlock
46 | SlackActionsBlock;
47
48/**
49 * Escapes special characters for Slack mrkdwn format
50 * Reference: https://api.slack.com/reference/surfaces/formatting#escaping
51 *
52 * @param text - Text to escape
53 * @returns Escaped text safe for Slack mrkdwn
54 *
55 * @example
56 * escapeSlackText("Hello & <world>") // "Hello & <world>"
57 */
58export function escapeSlackText(text: string): string {
59 return text
60 .replace(/&/g, "&")
61 .replace(/</g, "<")
62 .replace(/>/g, ">");
63}
64
65/**
66 * Builds Slack blocks for alert notifications
67 *
68 * Layout:
69 * - Header: "{monitor.name} is failing"
70 * - Section: "METHOD URL" in code format (e.g., `GET https://api.example.com`)
71 * - Divider
72 * - Section: 4 fields in 2x2 grid (Status, Regions, Latency, Cron Timestamp)
73 * - Section: Error message in code block
74 * - Actions: Dashboard button
75 *
76 * @param data - Formatted message data from buildCommonMessageData
77 * @returns Array of Slack blocks
78 *
79 * @example
80 * const blocks = buildAlertBlocks({
81 * monitorName: "API Health",
82 * monitorUrl: "https://api.example.com",
83 * monitorMethod: "GET",
84 * monitorJobType: "http",
85 * statusCodeFormatted: "503 Service Unavailable",
86 * errorMessage: "Connection timeout",
87 * timestampFormatted: "Jan 22, 2026 at 14:30 UTC",
88 * regionsDisplay: "iad, fra, syd",
89 * latencyDisplay: "1,234 ms",
90 * dashboardUrl: "https://app.openstatus.dev/monitors/123"
91 * });
92 */
93export function buildAlertBlocks(data: FormattedMessageData): SlackBlock[] {
94 const escapedName = escapeSlackText(data.monitorName);
95 const escapedError = escapeSlackText(data.errorMessage);
96
97 // Format description as "METHOD URL" or just "URL" for non-HTTP
98 const description =
99 data.monitorMethod && data.monitorJobType === "http"
100 ? `${data.monitorMethod} <${data.monitorUrl}|${data.monitorUrl}>`
101 : `<${data.monitorUrl}|${data.monitorUrl}>`;
102
103 return [
104 {
105 type: "header",
106 text: {
107 type: "plain_text",
108 text: `${escapedName} is failing`,
109 emoji: false,
110 },
111 },
112 {
113 type: "section",
114 text: {
115 type: "mrkdwn",
116 text: `\`${description}\``,
117 },
118 },
119 {
120 type: "divider",
121 },
122 {
123 type: "section",
124 fields: [
125 {
126 type: "mrkdwn",
127 text: `*Status*\n${data.statusCodeFormatted}`,
128 },
129 {
130 type: "mrkdwn",
131 text: `*Regions*\n${data.regionsDisplay}`,
132 },
133 {
134 type: "mrkdwn",
135 text: `*Latency*\n${data.latencyDisplay}`,
136 },
137 {
138 type: "mrkdwn",
139 text: `*Cron Timestamp*\n${data.timestampFormatted}`,
140 },
141 ],
142 },
143 {
144 type: "section",
145 text: {
146 type: "mrkdwn",
147 text: `*Error*\n\`\`\`${escapedError}\`\`\``,
148 },
149 },
150 {
151 type: "actions",
152 elements: [
153 {
154 type: "button",
155 text: {
156 type: "plain_text",
157 text: "View Dashboard",
158 emoji: true,
159 },
160 url: data.dashboardUrl,
161 action_id: "view_dashboard",
162 },
163 ],
164 },
165 ];
166}
167
168/**
169 * Builds Slack blocks for recovery notifications
170 *
171 * Layout:
172 * - Header: "{monitor.name} is recovered"
173 * - Section: "METHOD URL" in code format (e.g., `GET https://api.example.com`)
174 * - Section: Downtime duration (optional, only if data.incidentDuration exists)
175 * - Divider
176 * - Section: 4 fields in 2x2 grid (Status, Regions, Latency, Cron Timestamp)
177 * - Actions: Dashboard button
178 *
179 * @param data - Formatted message data from buildCommonMessageData
180 * @returns Array of Slack blocks
181 *
182 * @example
183 * const blocks = buildRecoveryBlocks({
184 * monitorName: "API Health",
185 * monitorUrl: "https://api.example.com",
186 * monitorMethod: "GET",
187 * monitorJobType: "http",
188 * statusCodeFormatted: "200 OK",
189 * errorMessage: "",
190 * timestampFormatted: "Jan 22, 2026 at 14:35 UTC",
191 * regionsDisplay: "iad, fra, syd",
192 * latencyDisplay: "156 ms",
193 * dashboardUrl: "https://app.openstatus.dev/monitors/123",
194 * incidentDuration: "5m 30s"
195 * });
196 */
197export function buildRecoveryBlocks(data: FormattedMessageData): SlackBlock[] {
198 const escapedName = escapeSlackText(data.monitorName);
199
200 // Format description as "METHOD URL" or just "URL" for non-HTTP
201 const description =
202 data.monitorMethod && data.monitorJobType === "http"
203 ? `${data.monitorMethod} <${data.monitorUrl}|${data.monitorUrl}>`
204 : `<${data.monitorUrl}|${data.monitorUrl}>`;
205
206 const blocks: SlackBlock[] = [
207 {
208 type: "header",
209 text: {
210 type: "plain_text",
211 text: `${escapedName} is recovered`,
212 emoji: false,
213 },
214 },
215 {
216 type: "section",
217 text: {
218 type: "mrkdwn",
219 text: `\`${description}\``,
220 },
221 },
222 ];
223
224 // Only include downtime if incident duration is available
225 if (data.incidentDuration) {
226 blocks.push({
227 type: "section",
228 text: {
229 type: "mrkdwn",
230 text: `⏱️ *Downtime:* ${data.incidentDuration}`,
231 },
232 });
233 }
234
235 blocks.push(
236 {
237 type: "divider",
238 },
239 {
240 type: "section",
241 fields: [
242 {
243 type: "mrkdwn",
244 text: `*Status*\n${data.statusCodeFormatted}`,
245 },
246 {
247 type: "mrkdwn",
248 text: `*Regions*\n${data.regionsDisplay}`,
249 },
250 {
251 type: "mrkdwn",
252 text: `*Latency*\n${data.latencyDisplay}`,
253 },
254 {
255 type: "mrkdwn",
256 text: `*Cron Timestamp*\n${data.timestampFormatted}`,
257 },
258 ],
259 },
260 {
261 type: "actions",
262 elements: [
263 {
264 type: "button",
265 text: {
266 type: "plain_text",
267 text: "View Dashboard",
268 emoji: true,
269 },
270 url: data.dashboardUrl,
271 action_id: "view_dashboard",
272 },
273 ],
274 },
275 );
276
277 return blocks;
278}
279
280/**
281 * Builds Slack blocks for degraded notifications
282 *
283 * Layout:
284 * - Header: "{monitor.name} is degraded"
285 * - Section: "METHOD URL" in code format (e.g., `GET https://api.example.com`)
286 * - Section: Previous incident duration (optional, only if data.incidentDuration exists)
287 * - Divider
288 * - Section: 4 fields in 2x2 grid (Status, Regions, Latency, Cron Timestamp)
289 * - Actions: Dashboard button
290 *
291 * @param data - Formatted message data from buildCommonMessageData
292 * @returns Array of Slack blocks
293 *
294 * @example
295 * const blocks = buildDegradedBlocks({
296 * monitorName: "API Health",
297 * monitorUrl: "https://api.example.com",
298 * monitorMethod: "GET",
299 * monitorJobType: "http",
300 * statusCodeFormatted: "504 Gateway Timeout",
301 * errorMessage: "Slow response",
302 * timestampFormatted: "Jan 22, 2026 at 14:40 UTC",
303 * regionsDisplay: "iad, fra, syd",
304 * latencyDisplay: "5,234 ms",
305 * dashboardUrl: "https://app.openstatus.dev/monitors/123",
306 * incidentDuration: "2h 15m"
307 * });
308 */
309export function buildDegradedBlocks(data: FormattedMessageData): SlackBlock[] {
310 const escapedName = escapeSlackText(data.monitorName);
311
312 // Format description as "METHOD URL" or just "URL" for non-HTTP
313 const description =
314 data.monitorMethod && data.monitorJobType === "http"
315 ? `${data.monitorMethod} <${data.monitorUrl}|${data.monitorUrl}>`
316 : `<${data.monitorUrl}|${data.monitorUrl}>`;
317
318 const blocks: SlackBlock[] = [
319 {
320 type: "header",
321 text: {
322 type: "plain_text",
323 text: `${escapedName} is degraded`,
324 emoji: false,
325 },
326 },
327 {
328 type: "section",
329 text: {
330 type: "mrkdwn",
331 text: `\`${description}\``,
332 },
333 },
334 ];
335
336 // Only include previous incident duration if available
337 if (data.incidentDuration) {
338 blocks.push({
339 type: "section",
340 text: {
341 type: "mrkdwn",
342 text: `⏱️ *Previous Incident Duration:* ${data.incidentDuration}`,
343 },
344 });
345 }
346
347 blocks.push(
348 {
349 type: "divider",
350 },
351 {
352 type: "section",
353 fields: [
354 {
355 type: "mrkdwn",
356 text: `*Status*\n${data.statusCodeFormatted}`,
357 },
358 {
359 type: "mrkdwn",
360 text: `*Regions*\n${data.regionsDisplay}`,
361 },
362 {
363 type: "mrkdwn",
364 text: `*Latency*\n${data.latencyDisplay}`,
365 },
366 {
367 type: "mrkdwn",
368 text: `*Cron Timestamp*\n${data.timestampFormatted}`,
369 },
370 ],
371 },
372 {
373 type: "actions",
374 elements: [
375 {
376 type: "button",
377 text: {
378 type: "plain_text",
379 text: "View Dashboard",
380 emoji: true,
381 },
382 url: data.dashboardUrl,
383 action_id: "view_dashboard",
384 },
385 ],
386 },
387 );
388
389 return blocks;
390}