diff --git a/apps/server/slack-manifest.json b/apps/server/slack-manifest.json index f6953e78..7e667896 100644 --- a/apps/server/slack-manifest.json +++ b/apps/server/slack-manifest.json @@ -3,7 +3,7 @@ "name": "openstatus", "description": "Run incidents and keep your status page up to date, without leaving Slack.", "background_color": "#000000", - "long_description": "openstatus runs your incidents and status page from Slack. Declare an incident and it opens a dedicated channel for it. Mention @openstatus in that channel or any thread, or open it from the Slack top bar, and it turns the conversation into status page updates. Nothing is published until you approve it.\n\nIncidents\n:rotating_light: Declare an incident from the Declare incident shortcut, a message's ⋯ menu, or /openstatus incident declare\n:hash: openstatus opens a dedicated channel and adds you to it\n:memo: Add notes to the timeline, mark the incident mitigated or resolved, and draft the postmortem\n\nStatus page updates\n:speech_balloon: Mention @openstatus in a thread and it drafts a status report from the discussion\n:white_check_mark: Click Approve to publish it, or Approve & Notify to also notify your subscribers\n:arrows_counterclockwise: Say \"we found the cause\" or \"it's fixed\" and it moves the report to Identified or Resolved\n:calendar: Schedule maintenance windows in plain language\n\nStatus page subscriptions\n:bell: /openstatus subscribe posts a status page's updates in a channel\n\nAI disclaimer\nopenstatus uses large language models (LLMs) to summarize conversations, draft status updates, and infer incident status. AI-generated content may be inaccurate or incomplete, so review every draft before approving it. Nothing is posted to your status page until you confirm, only the person who triggered the action can approve it, and you can cancel any draft." + "long_description": "openstatus runs your incidents and status page from Slack. Declare an incident and it opens a dedicated channel for it. Mention @openstatus in that channel or any thread, or open it from the Slack top bar, and it turns the conversation into status page updates. Nothing is published until you approve it.\n\nIncidents\n:rotating_light: Declare an incident from the Declare incident shortcut, a message's ⋯ menu, or /openstatus incident declare\n:hash: openstatus opens a dedicated channel and adds you to it\n:memo: Add any message to the timeline from its ⋯ menu or with a :pushpin: reaction, write notes, mark the incident mitigated or resolved, and draft the postmortem\n\nStatus page updates\n:speech_balloon: Mention @openstatus in a thread and it drafts a status report from the discussion\n:white_check_mark: Click Approve to publish it, or Approve & Notify to also notify your subscribers\n:arrows_counterclockwise: Say \"we found the cause\" or \"it's fixed\" and it moves the report to Identified or Resolved\n:calendar: Schedule maintenance windows in plain language\n\nStatus page subscriptions\n:bell: /openstatus subscribe posts a status page's updates in a channel\n\nAI disclaimer\nopenstatus uses large language models (LLMs) to summarize conversations, draft status updates, and infer incident status. AI-generated content may be inaccurate or incomplete, so review every draft before approving it. Nothing is posted to your status page until you confirm, only the person who triggered the action can approve it, and you can cancel any draft." }, "features": { "agent_view": { @@ -48,6 +48,12 @@ "type": "message", "callback_id": "declare_incident_from_message", "description": "Declare an incident from this message" + }, + { + "name": "Add to incident timeline", + "type": "message", + "callback_id": "add_to_incident_timeline", + "description": "Add this message to the incident's timeline" } ], "slash_commands": [ diff --git a/apps/server/src/routes/slack/commands.ts b/apps/server/src/routes/slack/commands.ts index 012bea8b..a70cd5ee 100644 --- a/apps/server/src/routes/slack/commands.ts +++ b/apps/server/src/routes/slack/commands.ts @@ -10,14 +10,10 @@ import type { Context } from "hono"; import { z } from "zod"; import { runInBackground } from "./background"; -import { - type Block, - buildLinkAccountBlocks, - LINK_ACCOUNT_TEXT, -} from "./blocks"; +import { buildLinkAccountBlocks, LINK_ACCOUNT_TEXT } from "./blocks"; import type { SlackConfig, SlackEnv } from "./config"; import { runIncidentCommand } from "./incident-commands"; -import { openDeclareIncidentModal } from "./incident-modal"; +import { NOT_CONNECTED, openDeclareIncidentModal } from "./incident-modal"; import { linkAccountUrl, planRequiredMessage, @@ -25,6 +21,7 @@ import { slackAgentAllowed, } from "./require-slack-member"; import { resolvePageFromUrl } from "./resolve-page"; +import { type EphemeralReply, respondLater } from "./response-url"; import { resolveWorkspace } from "./workspace-resolver"; const logger = getLogger("api-server"); @@ -50,30 +47,12 @@ const HELP = [ "• `/openstatus subscriptions` — show this channel's subscriptions", ].join("\n"); -type CommandReply = { text: string; blocks?: Block[] }; +type CommandReply = EphemeralReply; function ephemeral(c: Context, reply: CommandReply) { return c.json({ response_type: "ephemeral", ...reply }); } -/** Deliver a reply after the ack, via the command's single-use response URL. */ -async function respondLater( - responseUrl: string, - reply: CommandReply, -): Promise { - const res = await fetch(responseUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ response_type: "ephemeral", ...reply }), - }); - if (!res.ok) { - logger.error("slack response_url delivery failed", { - status: res.status, - body: await res.text().catch(() => ""), - }); - } -} - async function joinChannel(teamId: string, channelId: string): Promise { try { const resolved = await resolveWorkspace(teamId); @@ -202,7 +181,7 @@ async function runMemberCommand( const resolved = await resolveWorkspace(command.team_id); if (!resolved) { return { - text: "openstatus isn't connected to this Slack workspace. Connect it from the openstatus dashboard.", + text: NOT_CONNECTED, }; } if (!slackAgentAllowed(resolved.workspace)) { diff --git a/apps/server/src/routes/slack/handler.test.ts b/apps/server/src/routes/slack/handler.test.ts index 42720c7d..7ec20486 100644 --- a/apps/server/src/routes/slack/handler.test.ts +++ b/apps/server/src/routes/slack/handler.test.ts @@ -1,7 +1,8 @@ import crypto from "node:crypto"; -import { db, eq } from "@openstatus/db"; +import { and, db, eq, inArray } from "@openstatus/db"; import { + auditLog, incident, incidentEvent, integration, @@ -2041,6 +2042,29 @@ describe("incident channel context", () => { }); }); +/** Drops an incident's events and the audit rows they wrote (no FK on those). */ +async function deleteIncidentEvents(incidentId: number) { + const events = await db + .select({ id: incidentEvent.id }) + .from(incidentEvent) + .where(eq(incidentEvent.incidentId, incidentId)) + .all(); + if (events.length > 0) { + await db.delete(auditLog).where( + and( + eq(auditLog.entityType, "incident_event"), + inArray( + auditLog.entityId, + events.map((e) => String(e.id)), + ), + ), + ); + } + await db + .delete(incidentEvent) + .where(eq(incidentEvent.incidentId, incidentId)); +} + describe("incident channel events", () => { const app = createTestApp(); let incidentId: number; @@ -2069,9 +2093,7 @@ describe("incident channel events", () => { }); afterEach(async () => { - await db - .delete(incidentEvent) - .where(eq(incidentEvent.incidentId, incidentId)); + await deleteIncidentEvents(incidentId); await db.delete(incident).where(eq(incident.id, incidentId)); }); @@ -2110,64 +2132,6 @@ describe("incident channel events", () => { expect(rows[0].message).toContain("https://slack.test/archives/"); }); - test("rich_text wins over mrkdwn: emoji, styles, mentions and channels", async () => { - // U1 is the pinner and must stay linked; U9 has no email, so the - // profile name is the only thing we know about them. - slackTestState.usersInfoImpl = (args) => - Promise.resolve( - args.user === "U9" - ? { ok: true, user: { real_name: "Jane Doe", profile: {} } } - : { ok: true, user: { profile: { email: "ping@openstatus.dev" } } }, - ); - slackTestState.conversationsInfoImpl = () => - Promise.resolve({ ok: true, channel: { name: "inc-db" } }); - slackTestState.historyImpl = () => - Promise.resolve({ - messages: [ - { - ts: "503.1", - user: "U2", - text: "*rolled back* :face_holding_back_tears: <@U9> see <#C7|inc-db>", - blocks: [ - { - type: "rich_text", - elements: [ - { - type: "rich_text_section", - elements: [ - { - type: "text", - text: "rolled back", - style: { bold: true }, - }, - { type: "text", text: " " }, - { - type: "emoji", - name: "face_holding_back_tears", - unicode: "1f979", - }, - { type: "text", text: " " }, - { type: "user", user_id: "U9" }, - { type: "text", text: " see " }, - { type: "channel", channel_id: "C7" }, - ], - }, - ], - }, - ], - }, - ], - }); - await pin("503.1"); - await waitForCall("reactions.add"); - const rows = await notes(); - expect(rows).toHaveLength(1); - expect(rows[0].message).toContain( - "**rolled back** 🥹 @Jane Doe see #inc-db", - ); - expect(rows[0].message).not.toContain(":face_holding_back_tears:"); - }); - test("a mention whose lookup fails keeps the raw id", async () => { slackTestState.usersInfoImpl = (args) => args.user === "U9" @@ -2206,92 +2170,6 @@ describe("incident channel events", () => { expect(rows[0].message).toContain("@U9 ack"); }); - test("the note keeps the time the message was said", async () => { - const saidAt = Math.floor(Date.now() / 1000) - 3600; - const ts = `${saidAt}.000100`; - slackTestState.historyImpl = () => - Promise.resolve({ messages: [{ ts, text: "an hour ago" }] }); - await pin(ts); - await waitForCall("reactions.add"); - const rows = await notes(); - expect(rows).toHaveLength(1); - expect(Math.abs(rows[0].createdAt.getTime() - saidAt * 1000)).toBeLessThan( - 1000, - ); - }); - - test("the note belongs to the author, not the pinner", async () => { - const author = await createUser(); - await addUserToWorkspace(author.id, 1, "member"); - const authorSlackId = `U_AUTHOR_${crypto.randomUUID()}`; - slackTestState.usersInfoImpl = (args) => - Promise.resolve({ - ok: true, - user: { - profile: { - email: - args.user === authorSlackId - ? author.email - : "ping@openstatus.dev", - }, - }, - }); - slackTestState.historyImpl = () => - Promise.resolve({ - messages: [ - { ts: "508.1", text: "I rolled it back", user: authorSlackId }, - ], - }); - try { - await pin("508.1"); - await waitForCall("reactions.add"); - const rows = await notes(); - expect(rows).toHaveLength(1); - expect(rows[0].createdBy).toBe(author.id); - } finally { - // The note references the author; drop it before the user. - await db - .delete(incidentEvent) - .where(eq(incidentEvent.incidentId, incidentId)); - await db - .delete(slackUser) - .where(eq(slackUser.slackUserId, authorSlackId)); - await db - .delete(usersToWorkspaces) - .where(eq(usersToWorkspaces.userId, author.id)); - await db.delete(user).where(eq(user.id, author.id)); - } - }); - - test("a bot message is attributed to the pinner without a lookup", async () => { - // Apps with a bot user carry both `bot_id` and `user`. - slackTestState.historyImpl = () => - Promise.resolve({ - messages: [ - { - ts: "509.1", - text: "[FIRING] api 5xx", - bot_id: "B1", - user: "U_BOT", - }, - ], - }); - await pin("509.1"); - await waitForCall("reactions.add"); - const rows = await notes(); - expect(rows).toHaveLength(1); - const [pinner] = await db - .select({ id: user.id }) - .from(user) - .where(eq(user.email, "ping@openstatus.dev")); - expect(rows[0].createdBy).toBe(pinner.id); - expect( - slackTestState.calls.some( - (m) => m.method === "users.info" && m.args.user === "U_BOT", - ), - ).toBe(false); - }); - test("a failed author lookup releases the pin for a retry", async () => { const author = await createUser(); await addUserToWorkspace(author.id, 1, "member"); @@ -2340,9 +2218,7 @@ describe("incident channel events", () => { expect(rows).toHaveLength(1); expect(rows[0].createdBy).toBe(author.id); } finally { - await db - .delete(incidentEvent) - .where(eq(incidentEvent.incidentId, incidentId)); + await deleteIncidentEvents(incidentId); await db .delete(slackUser) .where(eq(slackUser.slackUserId, authorSlackId)); @@ -2371,24 +2247,6 @@ describe("incident channel events", () => { expect(rows[0].message).toContain("[FIRING] db latency > 2s"); }); - test("an attachment with an empty fallback is noted from its text", async () => { - slackTestState.historyImpl = () => - Promise.resolve({ - messages: [ - { - ts: "507.1", - text: "", - attachments: [{ fallback: "", text: "Deploy 1234 failed" }], - }, - ], - }); - await pin("507.1"); - await waitForCall("reactions.add"); - const rows = await notes(); - expect(rows).toHaveLength(1); - expect(rows[0].message).toContain("Deploy 1234 failed"); - }); - test("a message with nothing to copy tells the pinner", async () => { slackTestState.historyImpl = () => Promise.resolve({ messages: [{ ts: "506.1", text: "" }] }); @@ -2431,6 +2289,23 @@ describe("incident channel events", () => { expect(await notes()).toHaveLength(1); }); + test("a 📌 outside an incident channel stays silent", async () => { + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_pin_${crypto.randomUUID()}`, + event: { + type: "reaction_added", + user: "U1", + reaction: "pushpin", + item: { type: "message", channel: "C_RANDOM", ts: "511.1" }, + }, + }); + await settleBackgroundTasks(); + await new Promise((r) => setTimeout(r, 50)); + expect(slackTestState.calls).toHaveLength(0); + }); + test("a pinned thread reply is found through the thread", async () => { slackTestState.historyImpl = () => Promise.resolve({ messages: [{ ts: "400.0", text: "parent" }] }); diff --git a/apps/server/src/routes/slack/incident-events.ts b/apps/server/src/routes/slack/incident-events.ts index e37f19ab..2837f8e7 100644 --- a/apps/server/src/routes/slack/incident-events.ts +++ b/apps/server/src/routes/slack/incident-events.ts @@ -15,13 +15,16 @@ import type { SlackConfig } from "./config"; import { trackSlackIncident } from "./incident-analytics"; import { linkAccountUrl, + planRequiredMessage, requireSlackMember, + type SlackActor, slackAgentAllowed, } from "./require-slack-member"; import { resolveSlackMember, resolveSlackMentionNames, } from "./resolve-slack-user"; +import { type EphemeralReply, respondLater } from "./response-url"; import { collectMentions, mentionLabelsFromText, @@ -31,10 +34,13 @@ import type { SlackWorkspace } from "./workspace-resolver"; const logger = getLogger(["api-server", "slack", "incident-events"]); +/** Callback id of the "Add to incident timeline" message shortcut. */ +export const ADD_TO_TIMELINE_CALLBACK = "add_to_incident_timeline"; + const PIN = "pushpin"; const DONE = "white_check_mark"; -type SlackMessage = { +export type SlackMessage = { ts?: string; text?: string; user?: string; @@ -45,6 +51,12 @@ type SlackMessage = { }; const NOTHING_TO_COPY = "Nothing to copy from that message."; +const NOT_AN_INCIDENT = + "This channel isn't an open incident's channel. Use *Add to incident timeline* or :pushpin: inside the incident's channel."; +const ALREADY_NOTED = "That message is already on the timeline."; +const NOTED = "Added to the timeline."; +const NOTE_FAILED = + "Couldn't add that message to the timeline. Please try again."; const PIN_FAILED = "Couldn't copy that message to the timeline. Pin it again to retry."; const VERB_LAG_MS = 60_000; @@ -57,7 +69,7 @@ function messageDate(ts: string): Date | undefined { const date = new Date(Number(ts) * 1000); const now = Date.now(); // The verb samples its own `now` a moment later; a boundary message must - // pass both, or the pin would fail instead of falling back to now. + // pass both, or the note would fail instead of falling back to now. return isAllowedNoteCreatedAt(date, now) && isAllowedNoteCreatedAt(date, now + VERB_LAG_MS) ? date @@ -146,85 +158,60 @@ async function alreadyNoted( ); } -/** 📌 on a message in an incident channel copies it onto the timeline. */ -export async function handlePinReaction(args: { - resolved: SlackWorkspace; - config: SlackConfig; - teamId: string; - slackUserId: string; - reaction: string; - channel: string; - ts: string; -}): Promise { - const { resolved, config, teamId, slackUserId, channel, ts } = args; - if (args.reaction !== PIN) return; - if (!slackAgentAllowed(resolved.workspace)) return; +/** The open incident bound to `channel`, if any. */ +async function openIncidentIn( + resolved: SlackWorkspace, + teamId: string, + channel: string, +) { const bound = await getIncidentBySlackChannel({ ctx: system(resolved), input: { teamId, channelId: channel }, }); - if (!bound || bound.closedAt) return; + return bound && !bound.closedAt ? bound : null; +} - const slack = new WebClient(resolved.botToken); - const actor = await requireSlackMember({ - workspace: resolved.workspace, - teamId, - slackUserId, - slack, - }); - if (!actor) { - const key = `slack:pinlink:${channel}:${ts}`; - const once = await redis.set(key, "1", { nx: true, ex: 24 * 60 * 60 }); - if (once === null) return; - try { - const url = await linkAccountUrl(config, { - workspaceId: resolved.workspace.id, - teamId, - slackUserId, - }); - await slack.chat.postEphemeral({ - channel, - user: slackUserId, - thread_ts: ts, - text: LINK_ACCOUNT_TEXT, - blocks: buildLinkAccountBlocks(url), - }); - } catch (err) { - // Otherwise a transient failure silences the card for the whole window. - await redis.del(key).catch(() => undefined); - throw err; - } - return; - } +type NoteOutcome = "noted" | "unconfirmed" | "already" | "empty"; - // Claimed before the ✅ check so two 📌 racing on one message note it once; - // it also stands in for the ✅ when adding the reaction fails. - const claim = `slack:pinned:${channel}:${ts}`; +/** + * Copies a message onto the incident's timeline and confirms it with ✅, + * shared by the 📌 reaction and the message shortcut. `unconfirmed` means the + * note was added but the ✅ wasn't. Throws, with the claim released, when the + * note could not be added. + */ +async function noteMessage(args: { + resolved: SlackWorkspace; + teamId: string; + slack: WebClient; + actor: SlackActor; + incidentId: number; + slackUserId: string; + channel: string; + ts: string; + loadMessage: () => Promise; + via: "reaction" | "shortcut"; +}): Promise { + const { resolved, teamId, slack, actor, slackUserId, channel, ts } = args; + // Claimed before the ✅ check so a 📌 and a click racing on one message + // note it once; it also stands in for the ✅ when adding the reaction fails. + const claim = `slack:timeline:${channel}:${ts}`; const claimed = await redis.set(claim, "1", { nx: true, ex: 24 * 60 * 60 }); - if (claimed === null) return; + if (claimed === null) return "already"; try { - if (await alreadyNoted(slack, channel, ts, resolved.botUserId)) return; - const message = await findMessage(slack, channel, ts); + if (await alreadyNoted(slack, channel, ts, resolved.botUserId)) { + return "already"; + } + const message = await args.loadMessage(); const body = message ? await noteBody({ resolved, teamId, slack, message }) : ""; if (!body) { await redis.del(claim); - await slack.chat - .postEphemeral({ - channel, - user: slackUserId, - thread_ts: ts, - text: NOTHING_TO_COPY, - }) - .catch((error) => - logger.warn("slack failed to report an empty pin", { error }), - ); - return; + return "empty"; } // The note belongs to whoever said it; bots and unlinked authors fall - // back to the pinner. A lookup hiccup throws so the claim is released and - // Slack retries, rather than pinning the wrong name forever. + // back to whoever pinned it or ran the shortcut. A lookup hiccup throws + // rather than attributing the note to the wrong name forever. const authorSlackId = message?.user && message.user !== slackUserId && @@ -252,15 +239,100 @@ export async function handlePinReaction(args: { await addIncidentNote({ ctx, input: { - id: bound.id, + id: args.incidentId, message: permalink ? `${body}\n\n[From Slack](${permalink})` : body, createdAt: messageDate(ts), createdBy: author ?? undefined, }, }); - trackSlackIncident(ctx, "note", { via: "reaction" }); + trackSlackIncident(ctx, "note", { via: args.via }); } catch (err) { await redis.del(claim).catch(() => undefined); + throw err; + } + // The ✅ shows everyone the message is on the timeline. + return slack.reactions + .add({ channel, timestamp: ts, name: DONE }) + .then((): NoteOutcome => "noted") + .catch((error) => { + logger.warn("slack failed to confirm a timeline note", { error }); + return "unconfirmed"; + }); +} + +/** + * 📌 on a message in an incident channel copies it onto the timeline. A + * reaction is ambient, so anything but a real attempt stays silent; a failure + * throws so Slack retries the event. + */ +export async function handlePinReaction(args: { + resolved: SlackWorkspace; + config: SlackConfig; + teamId: string; + slackUserId: string; + reaction: string; + channel: string; + ts: string; +}): Promise { + const { resolved, config, teamId, slackUserId, channel, ts } = args; + if (args.reaction !== PIN) return; + if (!slackAgentAllowed(resolved.workspace)) return; + const bound = await openIncidentIn(resolved, teamId, channel); + if (!bound) return; + + const slack = new WebClient(resolved.botToken); + const tell = (text: string, what: string) => + slack.chat + .postEphemeral({ channel, user: slackUserId, thread_ts: ts, text }) + .catch((error) => + logger.warn(`slack failed to report ${what}`, { error }), + ); + const actor = await requireSlackMember({ + workspace: resolved.workspace, + teamId, + slackUserId, + slack, + }); + if (!actor) { + const key = `slack:pinlink:${channel}:${ts}`; + const once = await redis.set(key, "1", { nx: true, ex: 24 * 60 * 60 }); + if (once === null) return; + try { + const url = await linkAccountUrl(config, { + workspaceId: resolved.workspace.id, + teamId, + slackUserId, + }); + await slack.chat.postEphemeral({ + channel, + user: slackUserId, + thread_ts: ts, + text: LINK_ACCOUNT_TEXT, + blocks: buildLinkAccountBlocks(url), + }); + } catch (err) { + // Otherwise a transient failure silences the card for the whole window. + await redis.del(key).catch(() => undefined); + throw err; + } + return; + } + + try { + const outcome = await noteMessage({ + resolved, + teamId, + slack, + actor, + incidentId: bound.id, + slackUserId, + channel, + ts, + loadMessage: () => findMessage(slack, channel, ts), + via: "reaction", + }); + if (outcome === "empty") await tell(NOTHING_TO_COPY, "an empty pin"); + } catch (err) { // Slack retries the event, but a lost pin should not go unnoticed if it // keeps failing; one notice per message is enough. const once = await redis @@ -269,23 +341,80 @@ export async function handlePinReaction(args: { ex: 24 * 60 * 60, }) .catch(() => null); - if (once !== null) { - await slack.chat - .postEphemeral({ - channel, - user: slackUserId, - thread_ts: ts, - text: PIN_FAILED, - }) - .catch((error) => - logger.warn("slack failed to report a lost pin", { error }), - ); - } + if (once !== null) await tell(PIN_FAILED, "a lost pin"); throw err; } - await slack.reactions - .add({ channel, timestamp: ts, name: DONE }) - .catch((error) => logger.warn("slack failed to confirm pin", { error })); +} + +/** + * The "Add to incident timeline" message shortcut: the ⋯-menu twin of 📌. + * The user asked for it, so every outcome but a ✅ is told to them through + * the shortcut's `response_url`. + */ +export async function handleAddToTimeline(args: { + resolved: SlackWorkspace; + config: SlackConfig; + teamId: string; + slackUserId: string; + channel: string; + message: SlackMessage & { ts: string }; + responseUrl: string; +}): Promise { + const { resolved, config, teamId, slackUserId, channel, message } = args; + const reply = (text: string, blocks?: EphemeralReply["blocks"]) => + respondLater(args.responseUrl, { text, blocks }).catch((error) => + logger.warn("slack failed to answer the timeline shortcut", { error }), + ); + + if (!slackAgentAllowed(resolved.workspace)) { + await reply(planRequiredMessage(config).text); + return; + } + const bound = await openIncidentIn(resolved, teamId, channel); + if (!bound) { + await reply(NOT_AN_INCIDENT); + return; + } + + const slack = new WebClient(resolved.botToken); + const actor = await requireSlackMember({ + workspace: resolved.workspace, + teamId, + slackUserId, + slack, + }); + if (!actor) { + const url = await linkAccountUrl(config, { + workspaceId: resolved.workspace.id, + teamId, + slackUserId, + }); + await reply(LINK_ACCOUNT_TEXT, buildLinkAccountBlocks(url)); + return; + } + + let outcome: NoteOutcome; + try { + outcome = await noteMessage({ + resolved, + teamId, + slack, + actor, + incidentId: bound.id, + slackUserId, + channel, + ts: message.ts, + // The shortcut carries the whole message; no need to look it up. + loadMessage: () => Promise.resolve(message), + via: "shortcut", + }); + } catch (err) { + await reply(NOTE_FAILED); + throw err; + } + if (outcome === "already") await reply(ALREADY_NOTED); + else if (outcome === "empty") await reply(NOTHING_TO_COPY); + else if (outcome === "unconfirmed") await reply(NOTED); } /** An archived or deleted channel no longer carries its incident. */ diff --git a/apps/server/src/routes/slack/incident-modal.ts b/apps/server/src/routes/slack/incident-modal.ts index c9771dc1..1a3dd585 100644 --- a/apps/server/src/routes/slack/incident-modal.ts +++ b/apps/server/src/routes/slack/incident-modal.ts @@ -26,7 +26,7 @@ import { resolveWorkspace, type SlackWorkspace } from "./workspace-resolver"; const logger = getLogger(["api-server", "slack", "incident-modal"]); -const NOT_CONNECTED = +export const NOT_CONNECTED = "openstatus isn't connected to this Slack workspace. Connect it from the openstatus dashboard."; /** Callback id of the global shortcut, the message shortcut and the modal. */ diff --git a/apps/server/src/routes/slack/interactions.test.ts b/apps/server/src/routes/slack/interactions.test.ts index 02489027..b3aaa954 100644 --- a/apps/server/src/routes/slack/interactions.test.ts +++ b/apps/server/src/routes/slack/interactions.test.ts @@ -1,11 +1,14 @@ import crypto from "node:crypto"; -import { and, db, eq } from "@openstatus/db"; +import { and, db, eq, inArray } from "@openstatus/db"; import { auditLog, incident, incidentEvent, selectWorkspaceSchema, + slackUser, + user, + usersToWorkspaces, workspace as workspaceTable, } from "@openstatus/db/src/schema"; import { @@ -15,7 +18,13 @@ import { createUser, } from "@openstatus/db/src/test/factories"; import { declareIncident } from "@openstatus/services/incident"; -import { beforeEach, describe, expect, test } from "@openstatus/test-utils"; +import { + afterEach, + beforeEach, + describe, + expect, + test, +} from "@openstatus/test-utils"; import { Hono } from "hono"; // workspace-resolver / @slack/web-api are swapped for doubles via the test @@ -781,3 +790,392 @@ describe("declare incident modal", () => { } }); }); + +/** Drops an incident's events and the audit rows they wrote (no FK on those). */ +async function deleteIncidentEvents(incidentId: number) { + const events = await db + .select({ id: incidentEvent.id }) + .from(incidentEvent) + .where(eq(incidentEvent.incidentId, incidentId)) + .all(); + if (events.length > 0) { + await db.delete(auditLog).where( + and( + eq(auditLog.entityType, "incident_event"), + inArray( + auditLog.entityId, + events.map((e) => String(e.id)), + ), + ), + ); + } + await db + .delete(incidentEvent) + .where(eq(incidentEvent.incidentId, incidentId)); +} + +describe("add to incident timeline shortcut", () => { + const app = createTestApp(); + const realFetch = globalThis.fetch; + let replies: { text?: string; blocks?: unknown[] }[]; + let incidentId: number; + let channelId: string; + + beforeEach(async () => { + configureSlackDoubles(); + redisStore.clear(); + slackTestState.resolveWorkspace = (teamId: string) => + teamId === "T_KNOWN" + ? Promise.resolve({ + botToken: "xoxb-fallback", + botUserId: "UBOT", + workspace: { id: 1, limits: { "slack-agent": true } }, + }) + : Promise.resolve(null); + slackTestState.reactionsGetImpl = () => + Promise.resolve({ ok: true, message: {} }); + slackTestState.conversationsInfoImpl = () => + Promise.resolve({ ok: true, channel: {} }); + replies = []; + globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => { + expect(String(url)).toBe("https://hooks.slack.test/response"); + replies.push(JSON.parse(String(init?.body))); + return Promise.resolve(new Response("ok")); + }) as typeof fetch; + channelId = `C_TIMELINE_${crypto.randomUUID()}`; + const [row] = await db + .insert(incident) + .values({ + workspaceId: 1, + title: "Timeline incident", + severity: "major", + declaredAt: new Date(), + startedAt: new Date(), + slackTeamId: "T_KNOWN", + slackChannelId: channelId, + }) + .returning(); + incidentId = row.id; + }); + + afterEach(async () => { + globalThis.fetch = realFetch; + await deleteIncidentEvents(incidentId); + await db.delete(incident).where(eq(incident.id, incidentId)); + }); + + async function notes() { + return db + .select() + .from(incidentEvent) + .where(eq(incidentEvent.incidentId, incidentId)) + .all(); + } + + function addToTimeline( + message: Record, + opts: { user?: string; channel?: string } = {}, + ) { + return signAndPost(app, { + type: "message_action", + callback_id: "add_to_incident_timeline", + trigger_id: "trig-timeline", + response_url: "https://hooks.slack.test/response", + team: { id: "T_KNOWN" }, + user: { id: opts.user ?? "U1" }, + channel: { id: opts.channel ?? channelId }, + message, + }); + } + + function reacted() { + return slackTestState.calls.some((c) => c.method === "reactions.add"); + } + + test("copies the message onto the timeline and confirms with ✅", async () => { + const res = await addToTimeline({ + ts: "500.1", + text: "Rolled back to v41", + user: "U2", + }); + expect(res.status).toBe(200); + const rows = await notes(); + expect(rows).toHaveLength(1); + expect(rows[0].message).toContain("Rolled back to v41"); + expect(rows[0].message).toContain("https://slack.test/archives/"); + const reaction = slackTestState.calls.find( + (c) => c.method === "reactions.add", + ); + expect(reaction?.args).toMatchObject({ + channel: channelId, + timestamp: "500.1", + name: "white_check_mark", + }); + expect(replies).toHaveLength(0); + }); + + test("rich_text wins over mrkdwn: emoji, styles, mentions and channels", async () => { + // U1 runs the shortcut and must stay linked; U9 has no email, so the + // profile name is the only thing we know about them. + slackTestState.usersInfoImpl = (args) => + Promise.resolve( + args.user === "U9" + ? { ok: true, user: { real_name: "Jane Doe", profile: {} } } + : { ok: true, user: { profile: { email: "ping@openstatus.dev" } } }, + ); + slackTestState.conversationsInfoImpl = () => + Promise.resolve({ ok: true, channel: { name: "inc-db" } }); + await addToTimeline({ + ts: "503.1", + user: "U2", + text: "*rolled back* :face_holding_back_tears: <@U9> see <#C7|inc-db>", + blocks: [ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { type: "text", text: "rolled back", style: { bold: true } }, + { type: "text", text: " " }, + { + type: "emoji", + name: "face_holding_back_tears", + unicode: "1f979", + }, + { type: "text", text: " " }, + { type: "user", user_id: "U9" }, + { type: "text", text: " see " }, + { type: "channel", channel_id: "C7" }, + ], + }, + ], + }, + ], + }); + const rows = await notes(); + expect(rows).toHaveLength(1); + expect(rows[0].message).toContain( + "**rolled back** 🥹 @Jane Doe see #inc-db", + ); + expect(rows[0].message).not.toContain(":face_holding_back_tears:"); + }); + + test("the note keeps the time the message was said", async () => { + const saidAt = Math.floor(Date.now() / 1000) - 3600; + await addToTimeline({ ts: `${saidAt}.000100`, text: "an hour ago" }); + const rows = await notes(); + expect(rows).toHaveLength(1); + expect(Math.abs(rows[0].createdAt.getTime() - saidAt * 1000)).toBeLessThan( + 1000, + ); + }); + + test("the note belongs to the author, not whoever ran the shortcut", async () => { + const author = await createUser(); + await addUserToWorkspace(author.id, 1, "member"); + const authorSlackId = `U_AUTHOR_${crypto.randomUUID()}`; + slackTestState.usersInfoImpl = (args) => + Promise.resolve({ + ok: true, + user: { + profile: { + email: + args.user === authorSlackId + ? author.email + : "ping@openstatus.dev", + }, + }, + }); + try { + await addToTimeline({ + ts: "508.1", + text: "I rolled it back", + user: authorSlackId, + }); + const rows = await notes(); + expect(rows).toHaveLength(1); + expect(rows[0].createdBy).toBe(author.id); + } finally { + // The note references the author; drop it before the user. + await deleteIncidentEvents(incidentId); + await db + .delete(slackUser) + .where(eq(slackUser.slackUserId, authorSlackId)); + await db + .delete(usersToWorkspaces) + .where(eq(usersToWorkspaces.userId, author.id)); + await db.delete(user).where(eq(user.id, author.id)); + } + }); + + test("a bot message is attributed to whoever ran the shortcut, without a lookup", async () => { + // Apps with a bot user carry both `bot_id` and `user`. + await addToTimeline({ + ts: "509.1", + text: "[FIRING] api 5xx", + bot_id: "B1", + user: "U_BOT", + }); + const rows = await notes(); + expect(rows).toHaveLength(1); + const [runner] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, "ping@openstatus.dev")); + expect(rows[0].createdBy).toBe(runner.id); + expect( + slackTestState.calls.some( + (m) => m.method === "users.info" && m.args.user === "U_BOT", + ), + ).toBe(false); + }); + + test("a failed author lookup tells the user and can be retried", async () => { + const author = await createUser(); + await addUserToWorkspace(author.id, 1, "member"); + const authorSlackId = `U_AUTHOR_${crypto.randomUUID()}`; + let flaky = true; + slackTestState.usersInfoImpl = (args) => { + if (args.user !== authorSlackId) { + return Promise.resolve({ + ok: true, + user: { profile: { email: "ping@openstatus.dev" } }, + }); + } + if (flaky) { + const err = new Error("An API error occurred: ratelimited"); + Object.assign(err, { data: { ok: false, error: "ratelimited" } }); + return Promise.reject(err); + } + return Promise.resolve({ + ok: true, + user: { profile: { email: author.email } }, + }); + }; + const message = { ts: "510.1", text: "flaky lookup", user: authorSlackId }; + try { + await addToTimeline(message); + expect(await notes()).toHaveLength(0); + expect(reacted()).toBe(false); + expect(replies.at(-1)?.text).toContain("Please try again"); + + flaky = false; + await addToTimeline(message); + const rows = await notes(); + expect(rows).toHaveLength(1); + expect(rows[0].createdBy).toBe(author.id); + } finally { + await deleteIncidentEvents(incidentId); + await db + .delete(slackUser) + .where(eq(slackUser.slackUserId, authorSlackId)); + await db + .delete(usersToWorkspaces) + .where(eq(usersToWorkspaces.userId, author.id)); + await db.delete(user).where(eq(user.id, author.id)); + } + }); + + test("a message with only attachments is noted from their fallback", async () => { + await addToTimeline({ + ts: "505.1", + text: "", + attachments: [{ fallback: "[FIRING] db latency > 2s" }], + }); + const rows = await notes(); + expect(rows).toHaveLength(1); + expect(rows[0].message).toContain("[FIRING] db latency > 2s"); + }); + + test("an attachment with an empty fallback is noted from its text", async () => { + await addToTimeline({ + ts: "507.1", + text: "", + attachments: [{ fallback: "", text: "Deploy 1234 failed" }], + }); + const rows = await notes(); + expect(rows).toHaveLength(1); + expect(rows[0].message).toContain("Deploy 1234 failed"); + }); + + test("a message with nothing to copy tells the user", async () => { + await addToTimeline({ ts: "506.1", text: "" }); + expect(replies.map((r) => r.text)).toEqual([ + "Nothing to copy from that message.", + ]); + expect(reacted()).toBe(false); + expect(await notes()).toHaveLength(0); + }); + + test("a message already confirmed is not noted twice", async () => { + slackTestState.reactionsGetImpl = () => + Promise.resolve({ + ok: true, + message: { + reactions: [{ name: "white_check_mark", users: ["UBOT"] }], + }, + }); + await addToTimeline({ ts: "501.1", text: "Twice" }); + expect(replies.at(-1)?.text).toContain("already on the timeline"); + expect(reacted()).toBe(false); + expect(await notes()).toHaveLength(0); + }); + + test("two clicks racing on one message note it once", async () => { + await Promise.all([ + addToTimeline({ ts: "502.1", text: "Once" }), + addToTimeline({ ts: "502.1", text: "Once" }, { user: "U3" }), + ]); + expect(await notes()).toHaveLength(1); + }); + + test("outside an incident channel it says where it works", async () => { + await addToTimeline( + { ts: "511.1", text: "hello" }, + { channel: "C_RANDOM" }, + ); + expect(replies.at(-1)?.text).toContain("isn't an open incident's channel"); + expect(slackTestState.calls).toHaveLength(0); + }); + + test("a closed incident's channel is refused", async () => { + await db + .update(incident) + .set({ closedAt: new Date() }) + .where(eq(incident.id, incidentId)); + await addToTimeline({ ts: "512.1", text: "too late" }); + expect(replies.at(-1)?.text).toContain("isn't an open incident's channel"); + expect(await notes()).toHaveLength(0); + }); + + test("an unlinked user gets the link card", async () => { + slackTestState.usersInfoImpl = () => + Promise.resolve({ ok: true, user: { profile: {} } }); + await addToTimeline( + { ts: "513.1", text: "who am I" }, + { user: "U_STRANGER" }, + ); + expect(JSON.stringify(replies.at(-1)?.blocks)).toContain("Link account"); + expect(await notes()).toHaveLength(0); + }); + + test("a workspace openstatus isn't connected to is told so", async () => { + slackTestState.resolveWorkspace = () => Promise.resolve(null); + await addToTimeline({ ts: "515.1", text: "anyone there" }); + expect(replies.at(-1)?.text).toContain("isn't connected"); + expect(slackTestState.calls).toHaveLength(0); + }); + + test("a workspace without the Slack plan is told to upgrade", async () => { + slackTestState.resolveWorkspace = () => + Promise.resolve({ + botToken: "xoxb-fallback", + botUserId: "UBOT", + workspace: { id: 1, limits: { "slack-agent": false } }, + }); + await addToTimeline({ ts: "514.1", text: "upgrade me" }); + expect(replies.at(-1)?.text).toContain("Upgrade"); + expect(await notes()).toHaveLength(0); + }); +}); diff --git a/apps/server/src/routes/slack/interactions.ts b/apps/server/src/routes/slack/interactions.ts index dae249a4..fef7dab7 100644 --- a/apps/server/src/routes/slack/interactions.ts +++ b/apps/server/src/routes/slack/interactions.ts @@ -14,9 +14,15 @@ import type { SlackConfig, SlackEnv } from "./config"; import { consume, get } from "./confirmation-store"; import type { PendingAction } from "./confirmation-store"; import { OPEN_DECLARE_INCIDENT_ACTION } from "./home"; +import { + ADD_TO_TIMELINE_CALLBACK, + handleAddToTimeline, + type SlackMessage, +} from "./incident-events"; import { DECLARE_INCIDENT_CALLBACK, DECLARE_INCIDENT_FROM_MESSAGE_CALLBACK, + NOT_CONNECTED, openDeclareIncidentModal, submitDeclareIncident, type ViewSubmissionPayload, @@ -36,6 +42,7 @@ import { type SlackActor, slackAgentAllowed, } from "./require-slack-member"; +import { respondLater } from "./response-url"; import { toServiceCtx } from "./service-adapter"; import { resolveWorkspace } from "./workspace-resolver"; @@ -69,14 +76,18 @@ async function processIncidentBind(payload: SlackInteractionPayload) { }); } +const TIMELINE_UNAVAILABLE = + "Couldn't read that message from Slack. Please try again."; + interface SlackShortcutPayload { type: "shortcut" | "message_action" | "block_actions"; callback_id: string; trigger_id: string; + response_url?: string; user?: { id: string; team_id?: string }; team?: { id: string }; channel?: { id: string }; - message?: { text?: string }; + message?: SlackMessage; } async function handleShortcut( @@ -84,6 +95,45 @@ async function handleShortcut( payload: SlackShortcutPayload, ) { const teamId = payload.team?.id ?? payload.user?.team_id; + if (payload.callback_id === ADD_TO_TIMELINE_CALLBACK) { + const { user, channel, message, response_url } = payload; + if (!response_url) { + logger.warn("slack timeline shortcut without a response_url", { teamId }); + } else if (!teamId || !user?.id || !channel?.id || !message?.ts) { + logger.warn("slack timeline shortcut with an incomplete payload", { + teamId, + }); + runInBackground( + "add-to-timeline", + () => respondLater(response_url, { text: TIMELINE_UNAVAILABLE }), + { teamId }, + ); + } else { + const config = c.get("slackConfig"); + const ts = message.ts; + runInBackground( + "add-to-timeline", + async () => { + const resolved = await resolveWorkspace(teamId); + if (!resolved) { + await respondLater(response_url, { text: NOT_CONNECTED }); + return; + } + await handleAddToTimeline({ + resolved, + config, + teamId, + slackUserId: user.id, + channel: channel.id, + message: { ...message, ts }, + responseUrl: response_url, + }); + }, + { teamId }, + ); + } + return c.body(null, 200); + } if ( !teamId || !payload.user?.id || diff --git a/apps/server/src/routes/slack/response-url.ts b/apps/server/src/routes/slack/response-url.ts new file mode 100644 index 00000000..db8c39f6 --- /dev/null +++ b/apps/server/src/routes/slack/response-url.ts @@ -0,0 +1,30 @@ +import { getLogger } from "@logtape/logtape"; + +import type { Block } from "./blocks"; + +const logger = getLogger("api-server"); + +export type EphemeralReply = { text: string; blocks?: Block[] }; + +/** + * Deliver an ephemeral reply after the ack, via the `response_url` of a slash + * command or message shortcut (Slack accepts up to 5 replies within 30 + * minutes). Unlike `chat.postEphemeral`, it works in channels the bot is not a + * member of. + */ +export async function respondLater( + responseUrl: string, + reply: EphemeralReply, +): Promise { + const res = await fetch(responseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ response_type: "ephemeral", ...reply }), + }); + if (!res.ok) { + logger.error("slack response_url delivery failed", { + status: res.status, + body: await res.text().catch(() => ""), + }); + } +} diff --git a/apps/web/src/content/mdx-components/demo/incident-slack.tsx b/apps/web/src/content/mdx-components/demo/incident-slack.tsx index 88a6cc68..e08b1160 100644 --- a/apps/web/src/content/mdx-components/demo/incident-slack.tsx +++ b/apps/web/src/content/mdx-components/demo/incident-slack.tsx @@ -137,7 +137,7 @@ export function IncidentDeclareDemo() { ); } -/** The incident's own channel: pinned card on top, a pinned message becomes a timeline note. */ +/** The incident's own channel: pinned card on top, a message added from its ⋯ menu or with 📌 becomes a timeline note. */ export function IncidentChannelDemo() { return ( @@ -170,8 +170,8 @@ export function IncidentChannelDemo() {
- Open in openstatus · Pin a message with - 📌 to add it to the timeline. + Open in openstatus · Add a message to + the timeline: ⋯ → Add to incident timeline, or react 📌.
@@ -193,7 +193,7 @@ export function IncidentChannelDemo() {
- Pinned messages land on the timeline + Any message can go on the timeline Reminder after {response.staleAfterHours}h without an update diff --git a/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx b/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx index 2aefa95a..2cbcf16e 100644 --- a/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx +++ b/apps/web/src/content/pages/docs/guides/how-to-setup-slack-agent.mdx @@ -113,7 +113,7 @@ Approve the card and the incident exists. openstatus opens a channel named `inc- #### Keep the timeline -In the incident's channel, react to any message with 📌 and it becomes a note on the incident's timeline. Or write one directly: +In the incident's channel, open any message's ⋯ menu and choose **Add to incident timeline**, or react to it with 📌, and it becomes a note on the incident's timeline. Or write one directly: ``` /openstatus incident note Rolling back the 09:38 edge config deploy. diff --git a/apps/web/src/content/pages/docs/reference/incident-management.mdx b/apps/web/src/content/pages/docs/reference/incident-management.mdx index f9cd62e9..e7c73228 100644 --- a/apps/web/src/content/pages/docs/reference/incident-management.mdx +++ b/apps/web/src/content/pages/docs/reference/incident-management.mdx @@ -80,7 +80,7 @@ Requires the [Slack agent](/docs/guides/how-to-setup-slack-agent), which is avai Declaring an incident, from Slack or the dashboard, opens a channel named `inc-YYYY-MM-DD-` (UTC date). The declarer is invited, the topic shows severity, status and a link to the incident, and the incident card is pinned. -- React to a message with 📌 (`:pushpin:`) and it becomes a timeline note, attributed to its author and linked back to Slack. The bot confirms with ✅. +- Add a message to the timeline from its ⋯ menu (**Add to incident timeline**) or by reacting with 📌 (`:pushpin:`). It becomes a timeline note, attributed to its author and linked back to Slack. Thread replies work too. The bot confirms with ✅. - Status changes are announced in the channel and the topic is kept in sync. - The channel is archived when the incident is closed or canceled. diff --git a/apps/web/src/content/pages/product/incident-management.mdx b/apps/web/src/content/pages/product/incident-management.mdx index 54e1d031..73291bf9 100644 --- a/apps/web/src/content/pages/product/incident-management.mdx +++ b/apps/web/src/content/pages/product/incident-management.mdx @@ -111,7 +111,7 @@ The incident's own Slack channel, named inc-, the date and checkout-api-503s-in-
-Every incident gets a channel with the severity and status in its topic and the incident card pinned on top. React to any message with 📌 and it becomes a timeline note, with a link back to Slack. +Every incident gets a channel with the severity and status in its topic and the incident card pinned on top. Add any message to the timeline from its ⋯ menu or with a 📌 reaction, with a link back to Slack. If the incident goes quiet, the channel gets a reminder: after one hour for critical, four for major and a day for minor. diff --git a/packages/services/src/incident/slack-flow.ts b/packages/services/src/incident/slack-flow.ts index dd3d0a3c..2f90c6cd 100644 --- a/packages/services/src/incident/slack-flow.ts +++ b/packages/services/src/incident/slack-flow.ts @@ -327,7 +327,7 @@ export function headerBlocks( elements: [ { type: "mrkdwn", - text: `<${url}|Open in openstatus> · Pin a message with :pushpin: to add it to the timeline.`, + text: `<${url}|Open in openstatus> · Add a message to the timeline: ⋯ → *Add to incident timeline*, or react :pushpin:.`, }, ], },