Openstatus www.openstatus.dev
at main 390 lines 9.6 kB view raw
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 &amp; &lt;world&gt;" 57 */ 58export function escapeSlackText(text: string): string { 59 return text 60 .replace(/&/g, "&amp;") 61 .replace(/</g, "&lt;") 62 .replace(/>/g, "&gt;"); 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}