Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
TypeScript
at incident-api
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376import type { AnyAgentTool, ExtraFlag, SummaryLine,} from "@openstatus/services/agent-tools";
import { toMrkdwn } from "./mrkdwn";
interface TextObject { type: "plain_text" | "mrkdwn"; text: string; emoji?: boolean;}
interface SectionBlock { type: "section"; text: TextObject;}
interface ActionsBlock { type: "actions"; elements: ButtonElement[];}
interface DividerBlock { type: "divider";}
interface ContextBlock { type: "context"; elements: TextObject[];}
/** * Renders standard markdown โ the dialect the model actually writes โ rather * than Slack's mrkdwn, so tables, ordered lists and fenced code survive * instead of being flattened by `toMrkdwn`. */interface MarkdownBlock { type: "markdown"; text: string;}
interface ButtonElement { type: "button"; text: TextObject; action_id: string; value?: string; url?: string; style?: "primary" | "danger";}
export type Block = | SectionBlock | ActionsBlock | DividerBlock | ContextBlock | MarkdownBlock;
/** Slack caps all `markdown` blocks in one payload at 12,000 characters. */const MARKDOWN_BLOCK_LIMIT = 12_000;/** And a message's `text` at 40,000 โ past it the post is rejected outright. */const MESSAGE_TEXT_LIMIT = 40_000;const TRUNCATION_NOTICE = "\n\n_Answer truncated._";
/** * The agent's free-text answer as a Slack message. `text` carries the * mrkdwn-converted copy โ it is what notifications and screen readers use, and * the fallback when the answer is too long for a `markdown` block. */export function buildAnswerMessage(text: string): { text: string; blocks?: Block[];} { const fallback = toMrkdwn(text); if (!text.trim() || text.length > MARKDOWN_BLOCK_LIMIT) { return { text: capMessageText(fallback) }; } return { text: fallback, blocks: [{ type: "markdown", text }] };}
/** Nothing bounds the agent's answer, and an over-long post is rejected. */function capMessageText(text: string): string { if (text.length <= MESSAGE_TEXT_LIMIT) return text; const kept = MESSAGE_TEXT_LIMIT - TRUNCATION_NOTICE.length; return `${text.slice(0, kept)}${TRUNCATION_NOTICE}`;}
export const LINK_ACCOUNT_ACTION_ID = "link_account";
export const LINK_ACCOUNT_TEXT = "Link your openstatus account to use openstatus in Slack.";
export function buildLinkAccountBlocks(url: string): Block[] { return [ { type: "section", text: { type: "mrkdwn", text: "*Link your openstatus account*\nOnly members of this openstatus workspace can use openstatus in Slack. Link your account to continue โ the link is valid for 10 minutes.", }, }, { type: "actions", elements: [ { type: "button", text: { type: "plain_text", text: "Link account" }, action_id: LINK_ACCOUNT_ACTION_ID, url, style: "primary", }, ], }, ];}
/** * Action-id encoding. We need to round-trip both the pending action's id * and (when the tool declares one) the user's extraFlag choice. The * scheme is `<action>_<actionId>` with two affirmative actions when an * extraFlag exists: `approve` (flag off) and `approve_flag` (flag on). */export function approveActionId(actionId: string): string { return `approve_${actionId}`;}export function approveWithFlagActionId(actionId: string): string { return `approve_flag_${actionId}`;}export function cancelActionId(actionId: string): string { return `cancel_${actionId}`;}
export type ParsedActionId = | { kind: "approve"; flag: false; pendingId: string } | { kind: "approve"; flag: true; pendingId: string } | { kind: "cancel"; pendingId: string };
export function parseActionId(actionId: string): ParsedActionId | undefined { // Legacy: cards rendered before the registry-runner deploy used // `approve_notify_<id>`. Map to flag=true so the carrier lookup at // least surfaces ":x: This action has expired" โ without this branch // the click would be silently dropped, leaving the card stale-but- // interactive. Bounded by the 5-min TTL; safe to remove afterwards. if (actionId.startsWith("approve_notify_")) { return { kind: "approve", flag: true, pendingId: actionId.slice("approve_notify_".length), }; } if (actionId.startsWith("approve_flag_")) { return { kind: "approve", flag: true, pendingId: actionId.slice("approve_flag_".length), }; } if (actionId.startsWith("approve_")) { return { kind: "approve", flag: false, pendingId: actionId.slice("approve_".length), }; } if (actionId.startsWith("cancel_")) { return { kind: "cancel", pendingId: actionId.slice("cancel_".length), }; } return undefined;}
/** Escape `& < >` so Slack renders them as literal mrkdwn text. */function escapeText(text: string): string { return text .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">");}
/** Escape a string for use as Slack mrkdwn link text (`<url|text>`). */function escapeLinkText(text: string): string { return escapeText(text).replace(/\|/g, "โ");}
/** Escape a URL for use as the target of a Slack mrkdwn link (`<url|text>`). */function escapeLinkUrl(url: string): string { return escapeText(url).replace(/\|/g, "%7C");}
/** * Data resolvers the Slack surface injects so `buildConfirmationBlocks` can * turn `SummaryLineRef` descriptors into names. Resolution needs DB access, * which the edge-safe services layer that produces the refs must not do. */export interface RefResolvers { /** Page id โ dashboard link, or null when the page no longer exists. */ page: (pageId: number) => Promise<{ title: string; url: string } | null>; /** * Status report id โ its title and public URL (url null when its page is * gone), or null when the report doesn't exist in the workspace. */ statusReport?: ( statusReportId: number, ) => Promise<{ title: string; url: string | null } | null>; /** Page-component ids โ their names (missing ids simply absent). */ componentNames: (ids: number[]) => Promise<Map<number, string>>;}
// Internal ids mean nothing to the person approving, so the card never shows// one: id-only lines (e.g. "Report ID") are dropped, a page that can't be// named is dropped, and a component that can't be named shows as unknown.const ID_LABEL = /\bID$/i;const UNKNOWN = "_unknown_";
async function renderLine( line: SummaryLine, resolvers?: RefResolvers,): Promise<string | null> { const ref = line.ref; if (!ref) { return ID_LABEL.test(line.label) ? null : `*${line.label}:* ${escapeText(line.value)}`; } if (!resolvers) return null; try { switch (ref.kind) { case "page": { const link = await resolvers.page(ref.pageId); return link ? `*Page:* <${link.url}|${escapeLinkText(link.title)}>` : null; } case "components": { const names = await resolvers.componentNames(ref.componentIds); const value = ref.componentIds .map((id) => nameOrUnknown(names, id)) .join(", "); return `*${line.label}:* ${value}`; } case "componentImpacts": { const names = await resolvers.componentNames( ref.impacts.map((i) => i.pageComponentId), ); const value = ref.impacts .map( (i) => `${nameOrUnknown(names, i.pageComponentId)} โ ${i.impact}`, ) .join(", "); return `*${line.label}:* ${value}`; } } } catch { // A transient lookup failure drops just this line, rather than aborting // the whole confirmation card. } return null;}
function nameOrUnknown(names: Map<number, string>, id: number): string { const name = names.get(id); return name ? escapeText(name) : UNKNOWN;}
/** * The report a draft acts on (add update / update / resolve), found by its * `statusReportId` input. The services summary only carries the id โ which * the card never shows โ so Slack looks the title up itself. */async function resolveReport( input: unknown, resolvers?: RefResolvers,): Promise<{ title: string; url: string | null } | null> { if (!resolvers?.statusReport) return null; if (typeof input !== "object" || input === null) return null; const id = (input as { statusReportId?: unknown }).statusReportId; if (typeof id !== "number") return null; try { return await resolvers.statusReport(id); } catch { // Same as a failed ref lookup: drop the line, keep the card. return null; }}
/** * Build the Block Kit confirmation card from a tool's `approval.summarize()`. * Two affirmative buttons when an extraFlag exists; one otherwise. When a * summary line carries a `ref` and `resolvers` are supplied, raw ids are * replaced by entity names (a dashboard link for pages, component names for * component ids). The card never shows a raw id. */export async function buildConfirmationBlocks(args: { actionId: string; tool: AnyAgentTool; input: unknown; resolvers?: RefResolvers;}): Promise<Block[]> { const { actionId, tool, input, resolvers } = args; if (!tool.approval) { throw new Error( `slack blocks: tool "${tool.name}" has no approval metadata`, ); } const summary = tool.approval.summarize(input); const flag: ExtraFlag | undefined = tool.approval.extraFlags?.[0];
const report = await resolveReport(input, resolvers); const lines = [ report ? `*Report:* ${escapeText(report.title)}` : null, ...(await Promise.all(summary.lines.map((l) => renderLine(l, resolvers)))), ] .filter((l) => l !== null) .join("\n");
const buttons: ButtonElement[] = [ { type: "button", text: { type: "plain_text", text: "Approve", emoji: true }, action_id: approveActionId(actionId), style: "primary", }, ]; if (flag) { buttons.push({ type: "button", text: { type: "plain_text", text: `Approve & ${flag.label}`, emoji: true, }, action_id: approveWithFlagActionId(actionId), style: "primary", }); } buttons.push({ type: "button", text: { type: "plain_text", text: "Cancel", emoji: true }, action_id: cancelActionId(actionId), style: "danger", });
const blocks: Block[] = [ { type: "section", text: { type: "mrkdwn", text: `*${escapeText(summary.title)}*\n\n${lines}`, }, }, ]; if (report?.url) { blocks.push({ type: "context", elements: [ { type: "mrkdwn", text: `<${escapeLinkUrl(report.url)}|View report>` }, ], }); } blocks.push({ type: "divider" }, { type: "actions", elements: buttons }); return blocks;}
export function getConfirmationText(args: { tool: AnyAgentTool; input: unknown;}): string { if (!args.tool.approval) return `Confirm ${args.tool.name}`; // Rendered as the message `text` field, which Slack parses as mrkdwn โ escape // so an LLM/user-controlled title can't inject a link or other markup. return escapeText(args.tool.approval.summarize(args.input).title);}