From d2680e997c411230399ef33ade9e5660254f19f7 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Fri, 16 Oct 2026 15:32:53 +0200 Subject: [PATCH] slack: channel context, incident follow-ups, welcome actions - mentions see the channel before them (with author and UTC time), Slack message links are read with a membership check, and a thread remembers what its approved cards created - declaring offers "Link this channel instead" in the source channel and a drafted status report card in the incident channel - the status report form gets Started at, which moves the incident's start back when earlier - approved cards show the next step with buttons - the welcome card offers Catch up, Declare incident, Publish status update --- .../test/doubles/report-draft-model.mock.ts | 20 + .../src/libs/test/doubles/slack-test-state.ts | 22 +- .../libs/test/doubles/slack-web-api.mock.ts | 23 +- .../src/libs/test/slack-incident-fixtures.ts | 78 ++ apps/server/src/routes/slack/agent.ts | 33 +- .../src/routes/slack/channel-context.test.ts | 61 ++ .../src/routes/slack/channel-context.ts | 141 +++- .../routes/slack/confirmation-store.test.ts | 25 + .../src/routes/slack/confirmation-store.ts | 56 ++ apps/server/src/routes/slack/handler.test.ts | 224 +++++- apps/server/src/routes/slack/handler.ts | 115 ++- apps/server/src/routes/slack/incident-card.ts | 265 ++++++- .../server/src/routes/slack/incident-modal.ts | 16 +- .../server/src/routes/slack/incident-slack.ts | 181 ++++- .../src/routes/slack/interactions.test.ts | 689 +++++++++++++++++- apps/server/src/routes/slack/interactions.ts | 178 ++++- apps/server/src/routes/slack/model.ts | 5 + .../src/routes/slack/next-steps.test.ts | 53 ++ apps/server/src/routes/slack/next-steps.ts | 68 ++ .../src/routes/slack/report-draft-model.ts | 39 + .../src/routes/slack/report-draft.test.ts | 161 ++++ apps/server/src/routes/slack/report-draft.ts | 178 +++++ .../src/routes/slack/report-follow-up.ts | 150 ++++ apps/server/src/routes/slack/response-url.ts | 12 + .../src/routes/slack/slack-links.test.ts | 216 ++++++ apps/server/src/routes/slack/slack-links.ts | 215 ++++++ .../src/routes/slack/system-prompt.test.ts | 19 + apps/server/src/routes/slack/system-prompt.ts | 14 +- .../src/routes/slack/thread-memory.test.ts | 54 ++ apps/server/src/routes/slack/thread-memory.ts | 51 ++ .../src/routes/slack/thread-messages.test.ts | 60 ++ .../src/routes/slack/thread-messages.ts | 47 ++ apps/server/src/routes/slack/welcome.ts | 28 +- apps/server/test.importmap.json | 3 +- .../docs/guides/how-to-setup-slack-agent.mdx | 9 + .../docs/reference/incident-management.mdx | 12 + .../pages/product/incident-management.mdx | 2 + .../src/incident/__tests__/incident.test.ts | 23 + .../services/src/incident/slack-channel.ts | 5 +- 39 files changed, 3419 insertions(+), 132 deletions(-) create mode 100644 apps/server/src/libs/test/doubles/report-draft-model.mock.ts create mode 100644 apps/server/src/libs/test/slack-incident-fixtures.ts create mode 100644 apps/server/src/routes/slack/next-steps.test.ts create mode 100644 apps/server/src/routes/slack/next-steps.ts create mode 100644 apps/server/src/routes/slack/report-draft-model.ts create mode 100644 apps/server/src/routes/slack/report-draft.test.ts create mode 100644 apps/server/src/routes/slack/report-draft.ts create mode 100644 apps/server/src/routes/slack/report-follow-up.ts create mode 100644 apps/server/src/routes/slack/slack-links.test.ts create mode 100644 apps/server/src/routes/slack/slack-links.ts create mode 100644 apps/server/src/routes/slack/thread-memory.test.ts create mode 100644 apps/server/src/routes/slack/thread-memory.ts create mode 100644 apps/server/src/routes/slack/thread-messages.test.ts create mode 100644 apps/server/src/routes/slack/thread-messages.ts diff --git a/apps/server/src/libs/test/doubles/report-draft-model.mock.ts b/apps/server/src/libs/test/doubles/report-draft-model.mock.ts new file mode 100644 index 000000000..428440e6c --- /dev/null +++ b/apps/server/src/libs/test/doubles/report-draft-model.mock.ts @@ -0,0 +1,20 @@ +// Test double for slack/report-draft-model.ts, swapped in via --import-map so +// tests never call a model. Tests set `reportDraftState.impl`. +import type { ModelDraft } from "../../../routes/slack/report-draft-model.ts"; + +export type { ModelDraft }; + +type DraftArgs = { system: string; prompt: string }; + +export const reportDraftState: { + impl: (args: DraftArgs) => Promise; + calls: DraftArgs[]; +} = { + impl: () => Promise.reject(new Error("no draft configured")), + calls: [], +}; + +export function generateReportDraft(args: DraftArgs): Promise { + reportDraftState.calls.push(args); + return reportDraftState.impl(args); +} diff --git a/apps/server/src/libs/test/doubles/slack-test-state.ts b/apps/server/src/libs/test/doubles/slack-test-state.ts index 94b210fea..f035d1960 100644 --- a/apps/server/src/libs/test/doubles/slack-test-state.ts +++ b/apps/server/src/libs/test/doubles/slack-test-state.ts @@ -4,6 +4,21 @@ type SlackCall = { method: string; args: Record }; type Override = ((args: Record) => Promise) | null; +export type HistoryArgs = { + channel: string; + latest?: string; + oldest?: string; + inclusive?: boolean; + limit?: number; + cursor?: string; +}; +export type MembersArgs = { channel: string; cursor?: string; limit?: number }; +export type MembersResult = { + ok: boolean; + members: string[]; + response_metadata?: { next_cursor?: string }; +}; + export interface SlackTestState { resolveWorkspace: (teamId: string) => Promise; calls: SlackCall[]; @@ -23,7 +38,10 @@ export interface SlackTestState { | ((options?: unknown, actor?: unknown) => Promise) | null; repliesImpl: (args?: Record) => Promise; - historyImpl: () => Promise; + historyImpl: (args?: HistoryArgs) => Promise; + /** `conversations.members` result; the default lists only U1. */ + membersImpl: (args: MembersArgs) => Promise; + archiveImpl: (args: { channel: string }) => Promise<{ ok: boolean }>; /** `users.info` result; the default has no email, so no mapping is created. */ usersInfoImpl: (args: Record) => Promise; /** `reactions.get` result; the default message has no reactions. */ @@ -57,6 +75,8 @@ if (!g.__slackTestState) { usersInfoImpl: () => Promise.resolve({ ok: true, user: { profile: {} } }), reactionsGetImpl: () => Promise.resolve({ ok: true, message: {} }), conversationsInfoImpl: () => Promise.resolve({ ok: true, channel: {} }), + membersImpl: () => Promise.resolve({ ok: true, members: ["U1"] }), + archiveImpl: () => Promise.resolve({ ok: true }), } satisfies SlackTestState; } diff --git a/apps/server/src/libs/test/doubles/slack-web-api.mock.ts b/apps/server/src/libs/test/doubles/slack-web-api.mock.ts index 33d95f449..97da50f56 100644 --- a/apps/server/src/libs/test/doubles/slack-web-api.mock.ts +++ b/apps/server/src/libs/test/doubles/slack-web-api.mock.ts @@ -1,6 +1,10 @@ // Test double for @slack/web-api (only WebClient is used), swapped in via // --import-map. Captures chat calls into the shared slack test state. -import { slackTestState as s } from "./slack-test-state.ts"; +import { + type HistoryArgs, + type MembersArgs, + slackTestState as s, +} from "./slack-test-state.ts"; export class WebClient { chat = { @@ -72,19 +76,26 @@ export class WebClient { }; }; conversations = { - replies: (args: Record) => s.repliesImpl(args), + replies: (args: Record) => { + s.calls.push({ method: "conversations.replies", args }); + return s.repliesImpl(args); + }, join: (args: Record) => { s.calls.push({ method: "conversations.join", args }); return Promise.resolve({ ok: true }); }, - history: (args: Record) => { + history: (args: HistoryArgs) => { s.calls.push({ method: "conversations.history", args }); - return s.historyImpl(); + return s.historyImpl(args); }, info: (args: Record) => { s.calls.push({ method: "conversations.info", args }); return s.conversationsInfoImpl(args); }, + members: (args: MembersArgs) => { + s.calls.push({ method: "conversations.members", args }); + return s.membersImpl(args); + }, create: (args: Record) => { s.calls.push({ method: "conversations.create", args }); return Promise.resolve({ @@ -100,9 +111,9 @@ export class WebClient { s.calls.push({ method: "conversations.setTopic", args }); return Promise.resolve({ ok: true }); }, - archive: (args: Record) => { + archive: (args: { channel: string }) => { s.calls.push({ method: "conversations.archive", args }); - return Promise.resolve({ ok: true }); + return s.archiveImpl(args); }, }; pins = { diff --git a/apps/server/src/libs/test/slack-incident-fixtures.ts b/apps/server/src/libs/test/slack-incident-fixtures.ts new file mode 100644 index 000000000..5784680aa --- /dev/null +++ b/apps/server/src/libs/test/slack-incident-fixtures.ts @@ -0,0 +1,78 @@ +// Anonymised from a real incident: a table rename in staging dropped the app +// role's access. The outage began the evening before it was declared, the bot +// was added by hand to a channel another workflow created, and links and +// queries were dropped in the channel as the team debugged. + +export type FixtureMessage = { + user: string; + text: string; + ts: string; + bot_id?: string; + thread_ts?: string; +}; + +export const FIXTURE_TEAM_ID = "T_PROBO"; +export const FIXTURE_CHANNEL_ID = "C0PRODEMERGENCY"; +export const FIXTURE_BOT_USER_ID = "UBOT"; +export const FIXTURE_USERS = { + responder: "U_RESPONDER", + workflowOwner: "U_WORKFLOW_OWNER", + engineer: "U_ENGINEER", +} as const; + +/** 2026-10-15 21:10 UTC: the first error, the evening before the declare. */ +export const FIXTURE_STARTED_TS = "1792098600.000100"; +/** 2026-10-16 08:30 UTC: the bot is mentioned for the first time. */ +export const FIXTURE_MENTION_TS = "1792139400.000200"; + +const { responder, workflowOwner, engineer } = FIXTURE_USERS; + +/** Oldest first, as the conversation reads. */ +const conversation: FixtureMessage[] = [ + { + user: engineer, + text: "Seeing 500s on the dashboard API since the deploy, anyone else?", + ts: FIXTURE_STARTED_TS, + }, + { + user: workflowOwner, + text: ":rotating_light: Production Emergency opened by workflow. Owner: <@U_RESPONDER>", + ts: "1792099200.000100", + }, + { + user: engineer, + text: "Grafana: https://grafana.example.com/d/api-errors?from=1792098000000", + ts: "1792099800.000100", + }, + { user: responder, text: "lol same", ts: "1792100100.000100" }, + { + user: responder, + text: "```SELECT grantee, privilege_type FROM information_schema.role_table_grants WHERE table_name = 'accounts';```\nempty for app_user", + ts: "1792138800.000100", + }, + { + user: responder, + text: "Found it: the table rename in staging dropped the grants for app_user.", + ts: "1792139100.000100", + }, +]; + +/** Slack's `conversations.history` order: newest first. */ +export const busyChannelHistory: FixtureMessage[] = [...conversation].reverse(); + +/** A link to the first error, as pasted in the channel. */ +export const firstErrorPermalink = `https://probo.slack.com/archives/${FIXTURE_CHANNEL_ID}/p${FIXTURE_STARTED_TS.replace(".", "")}`; + +export const mentionOutsideThread: FixtureMessage = { + user: responder, + text: `<@${FIXTURE_BOT_USER_ID}> the problem started here ${firstErrorPermalink}, can you open a status report?`, + ts: FIXTURE_MENTION_TS, +}; + +/** Asked after only a status report was created, with no managed incident. */ +export const whatIsTheIncident: FixtureMessage = { + user: responder, + text: `<@${FIXTURE_BOT_USER_ID}> what's the incident?`, + ts: "1792139700.000200", + thread_ts: FIXTURE_MENTION_TS, +}; diff --git a/apps/server/src/routes/slack/agent.ts b/apps/server/src/routes/slack/agent.ts index a255501cf..e2a57fe14 100644 --- a/apps/server/src/routes/slack/agent.ts +++ b/apps/server/src/routes/slack/agent.ts @@ -1,7 +1,7 @@ import type { Workspace } from "@openstatus/db/src/schema/workspaces/validation"; import type { ServiceContext } from "@openstatus/services"; import { stepCountIs, streamText } from "ai"; -import type { ModelMessage, Tool } from "ai"; +import type { Tool } from "ai"; import { tb } from "@/libs/clients"; @@ -9,12 +9,10 @@ import { SLACK_AGENT_MODEL } from "./model"; import { buildSlackTools } from "./registry-runner"; import type { SlackActor } from "./require-slack-member"; import { buildSystemPrompt } from "./system-prompt"; - -interface SlackThreadMessage { - user?: string; - bot_id?: string; - text?: string; -} +import { + convertThreadToMessages, + type SlackThreadMessage, +} from "./thread-messages"; // A mention needs list_status_pages + list_page_components before it can draft, // and the model is told to check monitor status first — 5 left no headroom. @@ -53,27 +51,6 @@ export interface AgentOptions { contextNote?: string; } -function convertThreadToMessages( - thread: SlackThreadMessage[], - botUserId: string, -): ModelMessage[] { - const messages: ModelMessage[] = []; - for (const msg of thread) { - if (!msg.text) continue; - if (msg.bot_id || msg.user === botUserId) { - messages.push({ role: "assistant", content: msg.text }); - } else { - messages.push({ role: "user", content: msg.text }); - } - } - // The API requires the first message to have role "user". - // Drop any leading assistant messages (e.g. bot confirmations from a prior turn). - while (messages.length > 0 && messages[0].role !== "user") { - messages.shift(); - } - return messages; -} - export async function runAgent( workspace: Workspace, thread: SlackThreadMessage[], diff --git a/apps/server/src/routes/slack/channel-context.test.ts b/apps/server/src/routes/slack/channel-context.test.ts index 8c3fce7e6..0e44ce318 100644 --- a/apps/server/src/routes/slack/channel-context.test.ts +++ b/apps/server/src/routes/slack/channel-context.test.ts @@ -6,8 +6,11 @@ import { slackTestState } from "@/libs/test/doubles/slack-test-state"; import { channelContextTooling, + channelHistoryNote, + channelHistoryTooling, contextChannelId, contextUserId, + fetchChannelHistory, forgetContext, recallContext, rememberContext, @@ -122,3 +125,61 @@ describe("the read_slack_channel tool", () => { expect(result.error).toBe("not_in_channel"); }); }); + +describe("fetchChannelHistory", () => { + beforeEach(() => { + slackTestState.calls = []; + }); + + test("reads before a ts and keeps the newest within the size cap", async () => { + const long = "x".repeat(5_000); + slackTestState.historyImpl = () => + Promise.resolve({ + messages: Array.from({ length: 30 }, (_, i) => ({ + user: "U1", + text: `${30 - i}:${long}`, + ts: `${1792000000 + 30 - i}.0`, + })), + }); + const messages = await fetchChannelHistory( + new WebClient("xoxb-test"), + "C1", + { limit: 80, before: "1792000100.0" }, + ); + + expect(slackTestState.calls[0].args).toMatchObject({ + channel: "C1", + limit: 50, + latest: "1792000100.0", + }); + expect(messages.reduce((n, m) => n + m.text.length, 0)).toBeLessThanOrEqual( + 20_000, + ); + expect(messages.every((m) => m.text.length <= 2_000)).toBe(true); + expect(messages.at(-1)?.text.startsWith("30:")).toBe(true); + }); + + test("the note lists messages oldest first with UTC times", () => { + const note = channelHistoryNote("C1", [ + { user: "U1", text: "first", ts: "1792098600.0" }, + { user: "U2", text: "second", ts: "1792139400.0" }, + ]); + expect(note).toContain("<#C1>"); + expect(note).toContain("[2026-10-15 21:10 UTC] <@U1>: first"); + expect(note).toContain("Oldest ts here: 1792098600.0"); + expect(channelHistoryNote("C1", [])).toBeUndefined(); + }); + + test("the channel tool pages back with before", async () => { + slackTestState.historyImpl = () => + Promise.resolve({ messages: [{ user: "U1", text: "old", ts: "5.0" }] }); + const tools = channelHistoryTooling({ + slack: new WebClient("xoxb-test"), + channelId: "C1", + }); + const execute = tools.read_slack_channel.execute; + if (!execute) throw new Error("read_slack_channel has no execute"); + await execute({ before: "10.0" }, { toolCallId: "t1", messages: [] }); + expect(slackTestState.calls[0].args).toMatchObject({ latest: "10.0" }); + }); +}); diff --git a/apps/server/src/routes/slack/channel-context.ts b/apps/server/src/routes/slack/channel-context.ts index 09664219b..850ed9252 100644 --- a/apps/server/src/routes/slack/channel-context.ts +++ b/apps/server/src/routes/slack/channel-context.ts @@ -5,6 +5,8 @@ import { z } from "zod"; import { redis } from "@/libs/clients"; +import { formatSlackLine, formatSlackTime } from "./thread-messages"; + const logger = getLogger("api-server"); /** @@ -71,6 +73,46 @@ const MAX_CONTEXT_MESSAGES = 50; const MAX_MESSAGE_CHARS = 2_000; const MAX_TOTAL_CHARS = 20_000; +export type ChannelMessage = { user: string; text: string; ts: string }; + +/** Oldest first, newest kept when the size cap bites. */ +export async function fetchChannelHistory( + slack: WebClient, + channel: string, + opts: { limit: number; before?: string }, +): Promise { + const res = await slack.conversations.history({ + channel, + limit: Math.min(opts.limit, MAX_CONTEXT_MESSAGES), + ...(opts.before ? { latest: opts.before } : {}), + }); + const messages: ChannelMessage[] = []; + let budget = MAX_TOTAL_CHARS; + for (const m of res.messages ?? []) { + if (!m.text) continue; + if (budget <= 0) break; + const text = m.text.slice(0, Math.min(MAX_MESSAGE_CHARS, budget)); + budget -= text.length; + messages.push({ + user: m.user ?? m.bot_id ?? "unknown", + text, + ts: m.ts ?? "", + }); + } + return messages.reverse(); +} + +/** The earlier conversation, as a prompt note the agent reads before the thread. */ +export function channelHistoryNote( + channelId: string, + messages: ChannelMessage[], +): string | undefined { + if (!messages.length) return undefined; + return `Recent messages in <#${channelId}> before this conversation (oldest first, times in UTC): +${messages.map(formatSlackLine).join("\n")} +Oldest ts here: ${messages[0].ts} (pass it as \`before\` to read_slack_channel for older history).`; +} + const readChannelInput = z.object({ limit: z .number() @@ -78,50 +120,36 @@ const readChannelInput = z.object({ .min(1) .max(MAX_CONTEXT_MESSAGES) .optional() - .describe("How many recent messages to read. Defaults to 50."), + .describe("How many messages to read. Defaults to 50."), + before: z + .string() + .optional() + .describe( + "Only read messages older than this Slack ts, to page back past what you already have.", + ), }); -/** - * The tool and the prompt note that let the agent use what the user is - * currently looking at. - * - * The model is told about the channel but has to ask for it: reading is - * authorized by the user's request, not by them having the channel on screen. - * `<#C…>` renders as the channel's name client-side, which is why no - * `conversations.info` call (and no `channels:read` scope) is needed here. - */ -export function channelContextTooling(args: { - slack: WebClient; - channelId: string; -}): { tools: Record; contextNote: string } { - const { slack, channelId } = args; - - const readChannel = tool({ - description: - "Read the recent messages of the Slack channel the user is currently viewing, so you can summarize the incident discussed there. Only call this when the user's request refers to that conversation.", +function readChannelTool( + slack: WebClient, + channelId: string, + description: string, +): Tool { + return tool({ + description, inputSchema: readChannelInput, - execute: async ({ limit }) => { + execute: async ({ limit, before }) => { try { - const res = await slack.conversations.history({ - channel: channelId, + const messages = await fetchChannelHistory(slack, channelId, { limit: limit ?? MAX_CONTEXT_MESSAGES, + before, }); - // Slack returns newest first, which is also budget order — the - // discussion reads forwards once what fits has been kept. - const messages: Array<{ user: string; text: string; ts: string }> = []; - let budget = MAX_TOTAL_CHARS; - for (const m of res.messages ?? []) { - if (!m.text) continue; - if (budget <= 0) break; - const text = m.text.slice(0, Math.min(MAX_MESSAGE_CHARS, budget)); - budget -= text.length; - messages.push({ - user: m.user ?? m.bot_id ?? "unknown", - text, - ts: m.ts ?? "", - }); - } - return { channelId, messages: messages.reverse() }; + return { + channelId, + messages: messages.map((m) => ({ + ...m, + time: formatSlackTime(m.ts) ?? "", + })), + }; } catch (err) { const error = errorCode(err); logger.info("slack could not read the context channel", { @@ -134,9 +162,30 @@ export function channelContextTooling(args: { } }, }); +} +/** + * The tool and the prompt note that let the agent use what the user is + * currently looking at. + * + * The model is told about the channel but has to ask for it: reading is + * authorized by the user's request, not by them having the channel on screen. + * `<#C…>` renders as the channel's name client-side, which is why no + * `conversations.info` call (and no `channels:read` scope) is needed here. + */ +export function channelContextTooling(args: { + slack: WebClient; + channelId: string; +}): { tools: Record; contextNote: string } { + const { slack, channelId } = args; return { - tools: { read_slack_channel: readChannel }, + tools: { + read_slack_channel: readChannelTool( + slack, + channelId, + "Read the recent messages of the Slack channel the user is currently viewing, so you can summarize the incident discussed there. Only call this when the user's request refers to that conversation.", + ), + }, contextNote: ` Slack context: - The user is currently looking at the Slack channel <#${channelId}>. @@ -146,6 +195,20 @@ Slack context: }; } +/** For a mention in a channel, whose recent messages are already preloaded. */ +export function channelHistoryTooling(args: { + slack: WebClient; + channelId: string; +}): Record { + return { + read_slack_channel: readChannelTool( + args.slack, + args.channelId, + "Read older messages of this Slack channel, beyond the recent ones already in your context. Pass `before` with the oldest ts you have to page back.", + ), + }; +} + function errorCode(err: unknown): string { const data = (err as { data?: { error?: unknown } })?.data; return typeof data?.error === "string" ? data.error : "unknown_error"; diff --git a/apps/server/src/routes/slack/confirmation-store.test.ts b/apps/server/src/routes/slack/confirmation-store.test.ts index d8bb07c14..a670f1e5d 100644 --- a/apps/server/src/routes/slack/confirmation-store.test.ts +++ b/apps/server/src/routes/slack/confirmation-store.test.ts @@ -6,6 +6,8 @@ import { draftKey, findByThread, get, + listCreated, + recordCreated, replace, store, } from "./confirmation-store"; @@ -257,3 +259,26 @@ describe("confirmation-store", () => { }); }); }); + +describe("created records", () => { + beforeEach(() => { + redisStore.clear(); + }); + + test("records per thread, newest last, without duplicates", async () => { + await recordCreated("9.1", { kind: "status_report", id: 12, label: "API" }); + await recordCreated("9.1", { kind: "incident", id: 7, label: "API down" }); + await recordCreated("9.1", { kind: "status_report", id: 12, label: "API" }); + + expect(await listCreated("9.1")).toEqual([ + { kind: "incident", id: 7, label: "API down" }, + { kind: "status_report", id: 12, label: "API" }, + ]); + expect(await listCreated("9.2")).toEqual([]); + }); + + test("ignores a corrupted entry", async () => { + redisStore.set("slack:created:9.3", JSON.stringify([{ kind: "nope" }])); + expect(await listCreated("9.3")).toEqual([]); + }); +}); diff --git a/apps/server/src/routes/slack/confirmation-store.ts b/apps/server/src/routes/slack/confirmation-store.ts index 6c212fe00..633c6ef8d 100644 --- a/apps/server/src/routes/slack/confirmation-store.ts +++ b/apps/server/src/routes/slack/confirmation-store.ts @@ -70,6 +70,28 @@ export interface CarrierStore { payload: PendingPayload, ): Promise; replace(id: string, payload: PendingPayload): Promise; + recordCreated(threadTs: string, record: CreatedRecord): Promise; + listCreated(threadTs: string): Promise; +} + +const createdRecordSchema = z.object({ + kind: z.enum(["status_report", "incident", "maintenance"]), + id: z.number().int(), + label: z.string().optional(), +}); + +export type CreatedRecord = z.infer; + +const MAX_CREATED = 20; + +function appendCreated( + list: CreatedRecord[], + record: CreatedRecord, +): CreatedRecord[] { + const rest = list.filter( + (r) => !(r.kind === record.kind && r.id === record.id), + ); + return [...rest, record].slice(-MAX_CREATED); } /** @@ -81,6 +103,7 @@ export interface CarrierStore { const TTL_SECONDS = 30 * 24 * 60 * 60; const ACTION_PREFIX = "slack:action:"; const THREAD_PREFIX = "slack:thread:"; +const CREATED_PREFIX = "slack:created:"; function parse(raw: unknown): PendingAction | undefined { const data = typeof raw === "string" ? JSON.parse(raw) : raw; @@ -169,6 +192,24 @@ export function createRedisCarrierStore(): CarrierStore { ), ]); }, + + async recordCreated(threadTs, record) { + const list = await this.listCreated(threadTs); + await redis.set( + `${CREATED_PREFIX}${threadTs}`, + JSON.stringify(appendCreated(list, record)), + { ex: TTL_SECONDS }, + ); + }, + + async listCreated(threadTs) { + const raw = await redis.get(`${CREATED_PREFIX}${threadTs}`); + if (!raw) return []; + const parsed = z + .array(createdRecordSchema) + .safeParse(typeof raw === "string" ? JSON.parse(raw) : raw); + return parsed.success ? parsed.data : []; + }, }; } @@ -179,6 +220,7 @@ export function createRedisCarrierStore(): CarrierStore { export function createMemoryCarrierStore(): CarrierStore { const actions = new Map(); const threads = new Map(); + const created = new Map(); return { async put(action) { @@ -222,6 +264,14 @@ export function createMemoryCarrierStore(): CarrierStore { createdAt: Date.now(), }); }, + + async recordCreated(threadTs, record) { + created.set(threadTs, appendCreated(created.get(threadTs) ?? [], record)); + }, + + async listCreated(threadTs) { + return created.get(threadTs) ?? []; + }, }; } @@ -243,3 +293,9 @@ export const findByThread = ( defaultStore.findByThread(threadTs, payload); export const replace = (id: string, payload: PendingPayload): Promise => defaultStore.replace(id, payload); +export const recordCreated = ( + threadTs: string, + record: CreatedRecord, +): Promise => defaultStore.recordCreated(threadTs, record); +export const listCreated = (threadTs: string): Promise => + defaultStore.listCreated(threadTs); diff --git a/apps/server/src/routes/slack/handler.test.ts b/apps/server/src/routes/slack/handler.test.ts index 0369bd10e..19086d512 100644 --- a/apps/server/src/routes/slack/handler.test.ts +++ b/apps/server/src/routes/slack/handler.test.ts @@ -32,6 +32,13 @@ import { TEST_SIGNING_SECRET as SIGNING_SECRET, withSlackConfig, } from "@/libs/test/slack-config"; +import { + busyChannelHistory, + FIXTURE_CHANNEL_ID, + FIXTURE_MENTION_TS, + mentionOutsideThread, + whatIsTheIncident, +} from "@/libs/test/slack-incident-fixtures"; import type { SlackEnv } from "./config"; import { @@ -101,6 +108,8 @@ function resetSlackTestState() { Promise.resolve({ messages: [{ user: "U1", text: "channel message", ts: "1.1" }], }); + slackTestState.membersImpl = () => + Promise.resolve({ ok: true, members: ["U1"] }); slackTestState.streamAppendFailAfter = null; slackTestState.streamStopFail = false; slackTestState.repliesImpl = () => @@ -216,9 +225,11 @@ describe("handleSlackEvent", () => { const welcome = await waitForCall("postEphemeral"); expect(welcome?.args.channel).toBe("C_NEW"); expect(welcome?.args.user).toBe("U1"); - expect(JSON.stringify(welcome?.args.blocks)).toContain( - "open_publish_status_update", - ); + const blocks = JSON.stringify(welcome?.args.blocks); + expect(blocks).toContain("What do you want to do?"); + expect(blocks).toContain("welcome_catch_up"); + expect(blocks).toContain("open_publish_status_update"); + expect(blocks).not.toContain("open_declare_incident"); }); test("stays quiet when the bot joins on its own or a person joins", async () => { @@ -594,7 +605,9 @@ describe("handleSlackEvent", () => { }); await new Promise((r) => setTimeout(r, 100)); - expect(slackTestState.calls.length).toBe(0); + expect( + slackTestState.calls.filter((c) => c.method !== "conversations.replies"), + ).toHaveLength(0); }); test("replies in the agent pane without a mention", async () => { @@ -926,17 +939,21 @@ describe("handleSlackEvent", () => { }); expect(res.status).toBe(200); - await new Promise((r) => setTimeout(r, 100)); // Delivered as a fresh message when streaming, or by overwriting the // "Thinking..." placeholder when it isn't — either way the user sees it. - const errorMessage = slackTestState.calls.find( - (m) => - (m.method === "update" || m.method === "postMessage") && - typeof m.args.text === "string" && - m.args.text.includes("Something went wrong"), - ); - expect(errorMessage).toBeDefined(); + const findError = () => + slackTestState.calls.find( + (m) => + (m.method === "update" || m.method === "postMessage") && + typeof m.args.text === "string" && + m.args.text.includes("Something went wrong"), + ); + const deadline = Date.now() + 5000; + while (!findError() && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10)); + } + expect(findError()).toBeDefined(); }); test("does not throw when both runAgent and error update fail", async () => { @@ -1699,7 +1716,7 @@ describe("the channel the user is viewing", () => { ).toBe(false); }); - test("leaves channel turns alone", async () => { + test("reads the mentioned channel, not the one being viewed", async () => { await contextChanged([ { type: "slack#/types/channel_id", value: "C_INCIDENT" }, ]); @@ -1720,10 +1737,13 @@ describe("the channel the user is viewing", () => { }); await new Promise((r) => setTimeout(r, 100)); - // In a channel the agent already has the thread it was called into. expect(seen).toHaveLength(1); - expect(seen[0].contextNote).toBeUndefined(); - expect(seen[0].tools).toBeUndefined(); + expect(seen[0].contextNote).toContain("<#C_OTHER>"); + expect(seen[0].contextNote).not.toContain("C_INCIDENT"); + expect(Object.keys(seen[0].tools ?? {})).toEqual([ + "read_slack_channel", + "read_slack_message", + ]); }); }); @@ -2446,3 +2466,175 @@ describe("incident channel events", () => { expect(bound).toBeNull(); }); }); + +describe("channel history", () => { + const app = createTestApp(); + + beforeEach(resetSlackTestState); + + async function waitFor(done: () => boolean, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (!done() && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 10)); + } + } + + function captureNotes() { + const notes: Array = []; + slackTestState.runAgentOverride = (options) => { + notes.push( + (options as { contextNote?: string } | undefined)?.contextNote, + ); + return Promise.resolve({ + text: "ok", + toolResults: [], + finishReason: "stop", + stepCount: 1, + hitStepLimit: false, + aborted: false, + }); + }; + return notes; + } + + test("a mention outside a thread sees the channel before it, with times", async () => { + slackTestState.historyImpl = () => + Promise.resolve({ messages: busyChannelHistory }); + const notes = captureNotes(); + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_history_${crypto.randomUUID()}`, + event: { + type: "app_mention", + ...mentionOutsideThread, + channel: FIXTURE_CHANNEL_ID, + }, + }); + await waitFor(() => notes.length > 0); + await settleBackgroundTasks(); + + const history = slackTestState.calls.find( + (c) => c.method === "conversations.history", + ); + expect(history?.args).toMatchObject({ + channel: FIXTURE_CHANNEL_ID, + limit: 50, + latest: FIXTURE_MENTION_TS, + }); + expect(notes[0]).toContain( + "[2026-10-15 21:10 UTC] <@U_ENGINEER>: Seeing 500s on the dashboard API", + ); + expect(notes[0]).toContain("Found it: the table rename in staging"); + const first = notes[0]?.indexOf("Seeing 500s") ?? -1; + const last = notes[0]?.indexOf("Found it") ?? -1; + expect(first).toBeLessThan(last); + }); + + test("a mention in a thread also sees the messages before the thread", async () => { + slackTestState.historyImpl = () => + Promise.resolve({ + messages: [ + { user: "U2", text: "before the thread", ts: "1792000000.1" }, + ], + }); + const notes = captureNotes(); + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_history_thread_${crypto.randomUUID()}`, + event: { + type: "app_mention", + text: "<@UBOT> summarize", + user: "U1", + channel: "C1", + ts: "1792139500.1", + thread_ts: "1792139400.1", + }, + }); + await waitFor(() => notes.length > 0); + await settleBackgroundTasks(); + + const history = slackTestState.calls.find( + (c) => c.method === "conversations.history", + ); + expect(history?.args).toMatchObject({ + channel: "C1", + limit: 20, + latest: "1792139400.1", + }); + expect(notes[0]).toContain("before the thread"); + }); + + test("a Slack link in the mention is read before the turn", async () => { + slackTestState.historyImpl = () => + Promise.resolve({ messages: busyChannelHistory }); + slackTestState.conversationsInfoImpl = () => + Promise.resolve({ ok: true, channel: { is_private: false } }); + const notes = captureNotes(); + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_history_link_${crypto.randomUUID()}`, + event: { + type: "app_mention", + ...mentionOutsideThread, + ts: `${Date.now() / 1000}`, + channel: FIXTURE_CHANNEL_ID, + }, + }); + await waitFor(() => notes.length > 0); + await settleBackgroundTasks(); + + expect(notes[0]).toContain("Slack messages linked in the user's message"); + expect(notes[0]).toContain( + "2026-10-15 21:10 UTC (2026-10-15T21:10:00.000Z), <@U_ENGINEER>: Seeing 500s", + ); + }); + + test("a follow-up in the thread knows what was created there", async () => { + const redis = (globalThis as Record) + .__testRedisStore as Map; + redis.set( + `slack:created:${FIXTURE_MENTION_TS}`, + JSON.stringify([{ kind: "status_report", id: 12, label: "API errors" }]), + ); + const notes = captureNotes(); + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_history_memory_${crypto.randomUUID()}`, + event: { + type: "app_mention", + ...whatIsTheIncident, + channel: FIXTURE_CHANNEL_ID, + }, + }); + await waitFor(() => notes.length > 0); + await settleBackgroundTasks(); + + expect(notes[0]).toContain('status report #12 "API errors"'); + }); + + test("a history failure still answers, without the note", async () => { + slackTestState.historyImpl = () => Promise.reject(new Error("boom")); + const notes = captureNotes(); + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_history_fail_${crypto.randomUUID()}`, + event: { + type: "app_mention", + text: "<@UBOT> hi", + user: "U1", + channel: "C1", + ts: "1792139600.1", + }, + }); + await waitFor(() => notes.length > 0); + await settleBackgroundTasks(); + + expect(notes).toHaveLength(1); + expect(notes[0] ?? "").not.toContain("Recent messages"); + }); +}); diff --git a/apps/server/src/routes/slack/handler.ts b/apps/server/src/routes/slack/handler.ts index 3e36f6591..96ef4cbbb 100644 --- a/apps/server/src/routes/slack/handler.ts +++ b/apps/server/src/routes/slack/handler.ts @@ -24,15 +24,24 @@ import { } from "./blocks"; import { channelContextTooling, + channelHistoryNote, + channelHistoryTooling, contextChannelId, contextUserId, + fetchChannelHistory, forgetContext, recallContext, rememberContext, } from "./channel-context"; import type { SlackConfig, SlackEnv } from "./config"; import { makeRefResolvers } from "./confirmation-card"; -import { draftKey, findByThread, replace, store } from "./confirmation-store"; +import { + draftKey, + findByThread, + listCreated, + replace, + store, +} from "./confirmation-store"; import type { PendingPayload } from "./confirmation-store"; import { homeIncidents, publishHomeView, publishLinkAccountView } from "./home"; import { handleChannelGone, handlePinReaction } from "./incident-events"; @@ -58,13 +67,15 @@ import { } from "./require-slack-member"; import { abortTurn, broadcastStop, endTurn, startTurn } from "./running-turns"; import { permalinkFor } from "./service-adapter"; +import { linkedMessagesNote, slackLinkTools } from "./slack-links"; +import { createdNote } from "./thread-memory"; import { buildThreadTitle, isThreadTitled, markThreadTitled, renameThread, } from "./thread-title"; -import { buildWelcomeBlocks, WELCOME_TEXT } from "./welcome"; +import { buildWelcomeBlocks, CATCH_UP_REQUEST, WELCOME_TEXT } from "./welcome"; import { resolveWorkspace, type SlackWorkspace } from "./workspace-resolver"; const logger = getLogger("api-server"); @@ -199,6 +210,9 @@ export function looksLikeUncardedDraft(text: string): boolean { return (text.match(PROSE_DRAFT_FIELDS) ?? []).length >= 2; } +const HISTORY_BEFORE_MENTION = 50; +const HISTORY_BEFORE_THREAD = 20; + // Bounds the Slack calls (and the agent's context) on very long threads. const MAX_THREAD_PAGES = 5; @@ -328,6 +342,64 @@ async function sendLinkCard(args: { logger.info("slack link card sent", { teamId, slackUserId }); } +/** + * The welcome card's "Catch up": a visible message in the channel, then a turn + * in its thread as if the clicker had mentioned the agent there. The channel + * is only read because someone asked. + */ +export async function catchUpChannel(args: { + teamId: string; + channelId: string; + slackUserId: string; + config: SlackConfig; +}): Promise { + const { teamId, channelId, slackUserId, config } = args; + const resolved = await resolveWorkspace(teamId); + if (!resolved || !slackAgentAllowed(resolved.workspace)) return; + const slack = new WebClient(resolved.botToken); + const actor = await requireSlackMember({ + workspace: resolved.workspace, + teamId, + slackUserId, + slack, + }); + if (!actor) { + await sendLinkCard({ + config, + workspaceId: resolved.workspace.id, + teamId, + slackUserId, + post: (message) => + slack.chat.postEphemeral({ + channel: channelId, + user: slackUserId, + ...message, + }), + }); + return; + } + const root = await slack.chat.postMessage({ + channel: channelId, + text: `:eyes: Catching up on <#${channelId}> for <@${slackUserId}>.`, + }); + if (!root.ts) return; + await processEvent( + { + type: "event_callback", + team_id: teamId, + event: { + type: "app_mention", + user: slackUserId, + channel: channelId, + channel_type: "channel", + ts: root.ts, + text: `<@${resolved.botUserId}> ${CATCH_UP_REQUEST}`, + }, + }, + config, + ); +} + async function processEvent(body: SlackEvent, config: SlackConfig) { const event = body.event; if (!event) return; @@ -764,6 +836,32 @@ async function processEvent(body: SlackEvent, config: SlackConfig) { const context = contextChannel ? channelContextTooling({ slack, channelId: contextChannel }) : undefined; + const history = isAgentThread + ? undefined + : await fetchChannelHistory(slack, event.channel, { + limit: event.thread_ts + ? HISTORY_BEFORE_THREAD + : HISTORY_BEFORE_MENTION, + before: threadTs, + }).catch((error) => { + logger.info("slack channel history unavailable", { + error, + channel: event.channel, + }); + return []; + }); + const channelTools = isAgentThread + ? context?.tools + : channelHistoryTooling({ slack, channelId: event.channel }); + const linksNote = await linkedMessagesNote({ + slack, + text: event.text, + requesterId: event.user, + }); + const memoryNote = createdNote(await listCreated(threadTs).catch(() => [])); + const linkTools = event.user + ? slackLinkTools({ slack, requesterId: event.user }) + : undefined; const incidentNote = await boundIncidentNote({ workspace: resolved.workspace, actor, @@ -788,10 +886,17 @@ async function processEvent(body: SlackEvent, config: SlackConfig) { { events: reply.progress, signal: turn.signal, - tools: context?.tools, + tools: { ...channelTools, ...linkTools }, contextNote: - [context?.contextNote, incidentNote].filter(Boolean).join("\n\n") || - undefined, + [ + context?.contextNote, + history && channelHistoryNote(event.channel, history), + linksNote, + memoryNote, + incidentNote, + ] + .filter(Boolean) + .join("\n\n") || undefined, }, ); diff --git a/apps/server/src/routes/slack/incident-card.ts b/apps/server/src/routes/slack/incident-card.ts index 3b8f50407..6a4133795 100644 --- a/apps/server/src/routes/slack/incident-card.ts +++ b/apps/server/src/routes/slack/incident-card.ts @@ -10,6 +10,7 @@ import { announceIncidentChange, escapeMrkdwn, getIncident, + getIncidentForStatusReport, headerBlocks, quoteNote, setIncidentStatus, @@ -17,7 +18,10 @@ import { } from "@openstatus/services/incident"; import { listPages } from "@openstatus/services/page"; import { listPageComponents } from "@openstatus/services/page-component"; -import { notifyStatusReport } from "@openstatus/services/status-report"; +import { + getStatusReport, + notifyStatusReport, +} from "@openstatus/services/status-report"; import { type ModalView, WebClient } from "@slack/web-api"; import { z } from "zod"; @@ -35,6 +39,7 @@ import { import { afterIncidentTool, who } from "./incident-slack"; import { renderToolResult } from "./presenters"; import { executeRegistryAction, getRegistryTool } from "./registry-runner"; +import type { ReportDraft } from "./report-draft"; const logger = getLogger(["api-server", "slack", "incident-card"]); @@ -88,6 +93,7 @@ const metadataSchema = z.object({ channelId: z.string().optional(), messageTs: z.string().optional(), statusReportId: z.number().int().optional(), + followUpTs: z.string().optional(), }); type Metadata = z.infer; @@ -207,6 +213,11 @@ export function buildUpdateStatusModal( } type PageOption = { id: number; title: string }; +export type ReportPrefill = { + message?: string; + componentIds?: number[]; + impact?: Impact; +}; type ComponentOption = { id: number; name: string }; const notifyBlock = { @@ -221,7 +232,7 @@ const notifyBlock = { }, }; -function messageBlock(placeholder: string) { +function messageBlock(placeholder: string, initial?: string) { return { type: "input" as const, block_id: "message", @@ -232,15 +243,21 @@ function messageBlock(placeholder: string) { multiline: true, max_length: NOTE_MAX, placeholder: { type: "plain_text" as const, text: placeholder }, + ...(initial ? { initial_value: initial.slice(0, NOTE_MAX) } : {}), }, }; } /** Adds an update to the incident's linked report. */ export function buildStatusReportUpdateModal( - report: NonNullable, + report: Pick< + NonNullable, + "id" | "title" | "status" + >, meta: Metadata, + preset?: ReportStatus, ): ModalView { + const status = preset ?? report.status; return { type: "modal", callback_id: STATUS_PAGE_CALLBACK, @@ -264,10 +281,14 @@ export function buildStatusReportUpdateModal( type: "static_select", action_id: "value", options: options(REPORT_STATUSES), - initial_option: option(report.status, REPORT_STATUSES[report.status]), + initial_option: option(status, REPORT_STATUSES[status]), }, }, - messageBlock("We have identified the cause and are rolling out a fix."), + messageBlock( + status === "resolved" + ? "The issue is resolved and everything is operating normally." + : "We have identified the cause and are rolling out a fix.", + ), notifyBlock, { type: "context", @@ -289,8 +310,17 @@ export function buildStatusReportCreateModal(args: { pageId: number; components: ComponentOption[]; meta: Metadata; + startedAt?: number; + prefill?: ReportPrefill; }): ModalView { - const { incident, pages, pageId, components, meta } = args; + const { incident, pages, pageId, components, meta, prefill } = args; + const preselected = components.filter((c) => + prefill?.componentIds?.includes(c.id), + ); + const impact = prefill?.impact ?? "partial_outage"; + const startedAt = + args.startedAt ?? + Math.floor((incident?.startedAt ?? new Date()).getTime() / 1000); const page = pages.find((p) => p.id === pageId) ?? pages[0]; const componentBlocks = components.length ? [ @@ -310,6 +340,13 @@ export function buildStatusReportCreateModal(args: { options: components .slice(0, OPTIONS_MAX) .map((c) => option(String(c.id), c.name)), + ...(preselected.length + ? { + initial_options: preselected + .slice(0, OPTIONS_MAX) + .map((c) => option(String(c.id), c.name)), + } + : {}), }, }, { @@ -325,7 +362,7 @@ export function buildStatusReportCreateModal(args: { type: "static_select" as const, action_id: "value", options: options(IMPACTS), - initial_option: option("partial_outage", IMPACTS.partial_outage), + initial_option: option(impact, IMPACTS[impact]), }, }, ] @@ -388,8 +425,25 @@ export function buildStatusReportCreateModal(args: { ), }, }, + { + type: "input", + block_id: "started_at", + label: { type: "plain_text", text: "Started at" }, + hint: { + type: "plain_text", + text: "When users were first affected. The report starts here.", + }, + element: { + type: "datetimepicker", + action_id: "value", + initial_date_time: startedAt, + }, + }, ...componentBlocks, - messageBlock("We are investigating elevated error rates."), + messageBlock( + "We are investigating elevated error rates.", + prefill?.message, + ), notifyBlock, { type: "context", @@ -411,6 +465,8 @@ async function statusPageModal( incident: Incident | undefined, meta: Metadata, pageId?: number, + startedAt?: number, + prefill?: ReportPrefill, ): Promise { if (incident?.statusReport) { return buildStatusReportUpdateModal(incident.statusReport, meta); @@ -441,6 +497,8 @@ async function statusPageModal( pageId: page.id, components: components.map((c) => ({ id: c.id, name: c.name })), meta, + startedAt, + prefill, }); } @@ -489,6 +547,122 @@ export async function openIncidentCardModal(args: { }); } +/** The follow-up card's form: a new report for the incident, drafted. */ +export async function openReportFollowUpModal(args: { + teamId: string; + slackUserId: string; + triggerId: string; + incidentId: number; + channelId: string; + followUpTs: string; + draft?: ReportDraft; + config: SlackConfig; +}): Promise { + const checked = await gate( + args.teamId, + args.slackUserId, + args.config, + STATUS_PAGE_TITLE, + ); + if (!checked.ok) { + if (!checked.botToken) return; + await new WebClient(checked.botToken).views.open({ + trigger_id: args.triggerId, + view: checked.view, + }); + return; + } + const ctx: ServiceContext = { + workspace: checked.resolved.workspace, + actor: checked.actor, + }; + const incident = await getIncident({ ctx, input: { id: args.incidentId } }); + const meta: Metadata = { + incidentId: args.incidentId, + channelId: args.channelId, + followUpTs: args.followUpTs, + }; + const { draft } = args; + const view = !incident + ? noticeModal(GONE, undefined, STATUS_PAGE_TITLE) + : await statusPageModal( + ctx, + incident, + meta, + draft?.pageId, + draft?.startedAt, + draft && { + message: draft.message, + componentIds: draft.componentIds, + impact: draft.impact, + }, + ); + await new WebClient(checked.resolved.botToken).views.open({ + trigger_id: args.triggerId, + view, + }); +} + +/** A next-step button: an update to a report, or its resolution. */ +export async function openReportUpdateModal(args: { + teamId: string; + slackUserId: string; + triggerId: string; + statusReportId: number; + channelId?: string; + resolve: boolean; + config: SlackConfig; +}): Promise { + const checked = await gate( + args.teamId, + args.slackUserId, + args.config, + STATUS_PAGE_TITLE, + ); + if (!checked.ok) { + if (!checked.botToken) return; + await new WebClient(checked.botToken).views.open({ + trigger_id: args.triggerId, + view: checked.view, + }); + return; + } + const ctx: ServiceContext = { + workspace: checked.resolved.workspace, + actor: checked.actor, + }; + const [report, incident] = await Promise.all([ + getStatusReport({ ctx, input: { id: args.statusReportId } }).catch( + () => undefined, + ), + getIncidentForStatusReport({ + ctx, + input: { statusReportId: args.statusReportId }, + }), + ]); + const view = !report + ? noticeModal( + "This status report no longer exists.", + undefined, + STATUS_PAGE_TITLE, + ) + : report.status === "resolved" + ? noticeModal( + "This status report is already resolved.", + undefined, + STATUS_PAGE_TITLE, + ) + : buildStatusReportUpdateModal( + { id: report.id, title: report.title, status: report.status }, + { incidentId: incident?.id, channelId: args.channelId }, + args.resolve ? "resolved" : undefined, + ); + await new WebClient(checked.resolved.botToken).views.open({ + trigger_id: args.triggerId, + view, + }); +} + /** Opens the form to publish a status report that belongs to no incident. */ export async function openPublishStatusUpdateModal(args: { teamId: string; @@ -529,6 +703,7 @@ export async function changeStatusPagePage(args: { hash?: string; privateMetadata?: string; pageId: number; + startedAt?: number; config: SlackConfig; }): Promise { const meta = readMetadata(args.privateMetadata); @@ -553,7 +728,13 @@ export async function changeStatusPagePage(args: { await new WebClient(checked.resolved.botToken).views.update({ view_id: args.viewId, ...(args.hash ? { hash: args.hash } : {}), - view: await statusPageModal(ctx, incident, meta, args.pageId), + view: await statusPageModal( + ctx, + incident, + meta, + args.pageId, + args.startedAt, + ), }); } @@ -761,8 +942,12 @@ type StatusPageWrite = { | "add_status_report_update" | "resolve_status_report"; input: object; + startedAt?: Date; }; +// Slack's picker has minute precision; "now" must not read as the future. +const FUTURE_TOLERANCE_MS = 2 * 60 * 1000; + function readStatusPageWrite( meta: Metadata, values: StateValues, @@ -805,13 +990,19 @@ function readStatusPageWrite( const impact: Impact = pick(IMPACTS, values.impact?.value?.selected_option?.value) ?? "partial_outage"; + const startedAt = values.started_at?.value?.selected_date_time; + if (startedAt && startedAt * 1000 > Date.now() + FUTURE_TOLERANCE_MS) { + return { error: { started_at: "Pick a time that isn't in the future." } }; + } return { toolName: "create_status_report", + startedAt: startedAt ? new Date(startedAt * 1000) : undefined, input: { title, status, message, pageId, + ...(startedAt ? { date: new Date(startedAt * 1000).toISOString() } : {}), pageComponentIds: componentIds, ...(componentIds.length ? { @@ -853,6 +1044,37 @@ async function notifySubscribers( } } +/** Replaces the follow-up card's buttons with what happened. Best effort. */ +export async function settleFollowUp(args: { + slack: WebClient; + channelId: string; + ts: string; + text: string; +}): Promise { + await args.slack.chat + .update({ + channel: args.channelId, + ts: args.ts, + text: args.text, + blocks: [], + }) + .catch((error) => + logger.warn("slack report follow-up update failed", { error }), + ); +} + +/** A report that started earlier than its incident moves the incident back. */ +async function backdateIncident( + ctx: ServiceContext, + incidentId: number | undefined, + startedAt: Date | undefined, +): Promise { + if (incidentId === undefined || !startedAt) return; + const incident = await getIncident({ ctx, input: { id: incidentId } }); + if (!incident || startedAt >= incident.startedAt) return; + await updateIncident({ ctx, input: { id: incidentId, startedAt } }); +} + export async function submitStatusPage( payload: ViewSubmissionPayload, config: SlackConfig, @@ -873,12 +1095,17 @@ export async function submitStatusPage( let result: { input: unknown; output: unknown }; try { - result = await executeRegistryAction({ - tool, - ctx, - draftInput: write.input, - // Subscriber fan-out can outlast Slack's 3s window: it runs after the ack. - flags: { notify: false }, + result = await withTransaction(ctx, async (tx) => { + const txCtx = { ...ctx, db: tx }; + const written = await executeRegistryAction({ + tool, + ctx: txCtx, + draftInput: write.input, + // Subscriber fan-out can outlast Slack's 3s window: it runs after the ack. + flags: { notify: false }, + }); + await backdateIncident(txCtx, meta.incidentId, write.startedAt); + return written; }); } catch (error) { if (!(error instanceof ServiceError)) { @@ -894,6 +1121,14 @@ export async function submitStatusPage( runInBackground( "incident-card-status-page", async () => { + if (meta.followUpTs && meta.channelId) { + await settleFollowUp({ + slack: submitted.slack, + channelId: meta.channelId, + ts: meta.followUpTs, + text: `:white_check_mark: Status report created by ${who(ctx)}.`, + }); + } const notified = notify && (await notifySubscribers(ctx, result.output)); const rendered = await renderToolResult({ tool, diff --git a/apps/server/src/routes/slack/incident-modal.ts b/apps/server/src/routes/slack/incident-modal.ts index 97955270e..8451e87e6 100644 --- a/apps/server/src/routes/slack/incident-modal.ts +++ b/apps/server/src/routes/slack/incident-modal.ts @@ -12,7 +12,7 @@ import { LINK_ACCOUNT_TEXT, } from "./blocks"; import type { SlackConfig } from "./config"; -import { onIncidentDeclared } from "./incident-slack"; +import { offersRelink, onIncidentDeclared } from "./incident-slack"; import { getIncidentDashboardUrl } from "./page-urls"; import { executeRegistryAction, getRegistryTool } from "./registry-runner"; import { @@ -247,6 +247,7 @@ export type StateValues = Record< selected_option?: { value: string } | null; selected_user?: string | null; selected_options?: { value: string }[] | null; + selected_date_time?: number | null; } > >; @@ -376,7 +377,18 @@ export async function submitDeclareIncident( runInBackground( "incident-modal-declared", async () => { - const channel = await onIncidentDeclared(ctx, incidentId, config); + const channel = await onIncidentDeclared(ctx, incidentId, config, { + slack, + channelId: origin, + }); + // The channel already got the relink notice, visible to everyone. + if ( + origin && + channel.status === "bound" && + offersRelink(origin, channel.channelId) + ) { + return; + } const where = channel.status === "bound" || channel.status === "unbound" ? ` Join <#${channel.channelId}>.` diff --git a/apps/server/src/routes/slack/incident-slack.ts b/apps/server/src/routes/slack/incident-slack.ts index d0fe3ff7b..feb1a77ab 100644 --- a/apps/server/src/routes/slack/incident-slack.ts +++ b/apps/server/src/routes/slack/incident-slack.ts @@ -10,18 +10,20 @@ import { announceIncidentChange, bindIncidentSlackChannel, getIncident, + headerBlocks, type IncidentEffects, incidentEffects, type OpenChannelResult, openIncidentSlackChannel, type SlackClientFactory, } from "@openstatus/services/incident"; -import { WebClient } from "@slack/web-api"; +import { type KnownBlock, WebClient } from "@slack/web-api"; import { z } from "zod"; import type { SlackConfig } from "./config"; import { postConfirmationCard } from "./confirmation-card"; import { trackSlackIncident } from "./incident-analytics"; +import { postReportFollowUp } from "./report-follow-up"; import { requireSlackMember, slackAgentAllowed } from "./require-slack-member"; import type { SlackWorkspace } from "./workspace-resolver"; @@ -108,7 +110,11 @@ export async function afterIncidentTool(args: { const note = incidentInput.safeParse(args.input).data?.note; try { if (toolName === "declare_incident") { - await onIncidentDeclared(ctx, incidentId, config); + await onIncidentDeclared(ctx, incidentId, config, { + slack: args.slack, + channelId: args.channelId, + threadTs: args.threadTs, + }); return; } if (toolName === "update_incident") { @@ -155,11 +161,50 @@ export async function afterIncidentTool(args: { } } +export const INCIDENT_RELINK_ACTION = "incident_relink"; + +const relinkValue = z.object({ + incidentId: z.number().int(), + from: z.string(), +}); + +/** Only a channel can become the incident's home, not a DM. */ +export function offersRelink(sourceId: string, incidentChannelId: string) { + return /^[CG]/.test(sourceId) && sourceId !== incidentChannelId; +} + +export function relinkNoticeBlocks( + incidentId: number, + incidentChannelId: string, +): KnownBlock[] { + return [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:rotating_light: Incident #${incidentId} opened in <#${incidentChannelId}>.`, + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Link this channel instead" }, + action_id: INCIDENT_RELINK_ACTION, + value: JSON.stringify({ incidentId, from: incidentChannelId }), + }, + ], + }, + ]; +} + /** Commander email and the incident channel, after any Slack declare. */ export async function onIncidentDeclared( ctx: ServiceContext, incidentId: number, config: SlackConfig, + source?: { slack: WebClient; channelId?: string; threadTs?: string }, ): Promise { trackSlackIncident(ctx, "declare"); const effects = await slackEffects(ctx, config); @@ -186,6 +231,33 @@ export async function onIncidentDeclared( openSlackChannel: true, }))) ?? { status: "skipped" as const }; logger.info("slack incident channel", { incidentId, ...result }); + if ( + source?.channelId && + result.status === "bound" && + offersRelink(source.channelId, result.channelId) + ) { + await source.slack.chat + .postMessage({ + channel: source.channelId, + ...(source.threadTs ? { thread_ts: source.threadTs } : {}), + text: `Incident #${incidentId} opened in <#${result.channelId}>.`, + blocks: relinkNoticeBlocks(incidentId, result.channelId), + }) + .catch((error) => + logger.warn("slack relink notice failed", { error, incidentId }), + ); + } + if (source && (result.status === "bound" || result.status === "unbound")) { + await postReportFollowUp({ + ctx, + slack: source.slack, + incidentId, + channelId: result.channelId, + sourceChannelId: source.channelId, + }).catch((error) => + logger.warn("slack report follow-up failed", { error, incidentId }), + ); + } return result; } @@ -278,3 +350,108 @@ export async function bindChannelFromButton(args: { }), ); } + +/** + * "Link this channel instead": the incident moves to the channel it was + * declared in, and the channel opened for it is archived. + */ +export async function relinkIncidentChannel(args: { + resolved: SlackWorkspace; + config: SlackConfig; + teamId: string; + slackUserId: string; + channelId: string; + messageTs: string; + value: string | undefined; +}): Promise { + const { resolved, config, teamId, slackUserId, channelId, messageTs } = args; + let parsed: z.infer | undefined; + try { + parsed = relinkValue.safeParse(JSON.parse(args.value ?? "")).data; + } catch { + parsed = undefined; + } + if (!parsed || !slackAgentAllowed(resolved.workspace)) return; + const { incidentId, from } = parsed; + const slack = new WebClient(resolved.botToken); + const tell = (text: string) => + slack.chat.postEphemeral({ channel: channelId, user: slackUserId, text }); + + const actor = await requireSlackMember({ + workspace: resolved.workspace, + teamId, + slackUserId, + slack, + }); + if (!actor) { + await tell( + "Link your openstatus account first: mention @openstatus to get the link.", + ); + return; + } + const ctx: ServiceContext = { workspace: resolved.workspace, actor }; + const current = await getIncident({ ctx, input: { id: incidentId } }); + if (!current || current.closedAt) { + await tell(":x: This incident is closed."); + return; + } + if (current.slackChannelId !== from) { + await tell( + current.slackChannelId === channelId + ? "This channel is already the incident channel." + : ":x: This incident has moved to another channel since.", + ); + return; + } + let moved: Awaited>; + try { + moved = await bindIncidentSlackChannel({ + ctx, + input: { id: incidentId, teamId, channelId }, + }); + } catch (error) { + await tell( + error instanceof ServiceError + ? `:x: ${error.message}` + : ":x: Could not link this channel. Please try again.", + ); + return; + } + + // The move has committed: what follows is cosmetic and must not undo it. + const url = `${config.dashboardUrl}/incidents/${incidentId}`; + const header = await slack.chat + .postMessage({ + channel: channelId, + text: `Incident: ${moved.title}`, + blocks: headerBlocks(moved, url), + }) + .catch((error) => { + logger.warn("slack relink card failed", { error, incidentId }); + return undefined; + }); + if (header?.ts) { + await slack.pins + .add({ channel: channelId, timestamp: header.ts }) + .catch(() => undefined); + } + await slack.chat + .postMessage({ + channel: from, + text: `${who(ctx)} moved this incident to <#${channelId}>. This channel is archived.`, + }) + .catch(() => undefined); + await slack.conversations + .archive({ channel: from }) + .catch((error) => + logger.warn("slack relink archive failed", { error, incidentId, from }), + ); + await slack.chat + .update({ + channel: channelId, + ts: messageTs, + text: ":white_check_mark: This channel is now the incident channel.", + blocks: [], + }) + .catch(() => undefined); +} diff --git a/apps/server/src/routes/slack/interactions.test.ts b/apps/server/src/routes/slack/interactions.test.ts index c4c7cce70..8312e7a4f 100644 --- a/apps/server/src/routes/slack/interactions.test.ts +++ b/apps/server/src/routes/slack/interactions.test.ts @@ -19,7 +19,10 @@ import { createTestWorkspace, createUser, } from "@openstatus/db/src/test/factories"; -import { declareIncident } from "@openstatus/services/incident"; +import { + bindIncidentSlackChannel, + declareIncident, +} from "@openstatus/services/incident"; import { afterEach, beforeEach, @@ -27,19 +30,24 @@ import { expect, test, } from "@openstatus/test-utils"; +import { WebClient } from "@slack/web-api"; import { Hono } from "hono"; import { settleBackgroundTasks } from "@/libs/background"; // workspace-resolver / @slack/web-api are swapped for doubles via the test // import map; behavior is driven through this shared mutable state. +import { reportDraftState } from "@/libs/test/doubles/report-draft-model.mock"; import { slackTestState } from "@/libs/test/doubles/slack-test-state"; import { TEST_SIGNING_SECRET as SIGNING_SECRET, withSlackConfig, } from "@/libs/test/slack-config"; +import { busyChannelHistory } from "@/libs/test/slack-incident-fixtures"; import type { SlackEnv } from "./config"; +import { offersRelink, relinkNoticeBlocks } from "./incident-slack"; import { handleSlackInteraction } from "./interactions"; +import { postReportFollowUp } from "./report-follow-up"; import { verifySlackSignature } from "./verify"; const redisStore = (globalThis as Record) @@ -437,6 +445,22 @@ describe("registry-runner execution paths", () => { ); }); + test("an approved card is remembered on its thread", async () => { + seedCreateMaintenance(); + await signAndPost(app, { + type: "block_actions", + user: { id: "U_OWNER" }, + channel: { id: "C1" }, + message: { ts: "2.2" }, + team: { id: "T_KNOWN" }, + actions: [{ action_id: "approve_maint-001" }], + }); + const remembered = JSON.parse(redisStore.get("slack:created:2.1") ?? "[]"); + expect(remembered).toEqual([ + { kind: "maintenance", id: expect.any(Number), label: "DB Maintenance" }, + ]); + }); + test("approve_flag create_maintenance notifies", async () => { seedCreateMaintenance(); const res = await signAndPost(app, { @@ -1500,6 +1524,101 @@ describe("pinned incident card buttons", () => { ); }); + function startedAtOf(blocks: unknown[]) { + const block = blocks.find( + (b): b is { block_id: string; element: { initial_date_time: number } } => + typeof b === "object" && + b !== null && + "block_id" in b && + b.block_id === "started_at", + ); + return block?.element.initial_date_time; + } + + async function incidentRow() { + return db.select().from(incident).where(eq(incident.id, incidentId)).get(); + } + + const reportValues = (startedAt: number) => ({ + page: { incident_card_page: { selected_option: { value: "1" } } }, + title: { value: { value: "Elevated errors" } }, + status: { value: { selected_option: { value: "investigating" } } }, + started_at: { value: { selected_date_time: startedAt } }, + message: { value: { value: "We are looking into it." } }, + }); + + test("Started at defaults to when the incident started", async () => { + const row = await incidentRow(); + await click("incident_card_status_page"); + expect(startedAtOf(openedView().blocks)).toBe( + Math.floor((row?.startedAt.getTime() ?? 0) / 1000), + ); + }); + + test("an earlier Started at dates the report and moves the incident back", async () => { + const earlier = Math.floor(Date.now() / 1000) - 11 * 3600; + expect( + await ( + await submit("incident_card_status_page", reportValues(earlier)) + ).text(), + ).toBe(""); + const row = await incidentRow(); + expect(row?.startedAt.getTime()).toBe(earlier * 1000); + const updates = await db + .select() + .from(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, row?.statusReportId ?? -1)) + .all(); + expect(updates[0]?.date.getTime()).toBe(earlier * 1000); + }); + + test("a later Started at leaves the incident's start alone", async () => { + await db + .update(incident) + .set({ startedAt: new Date(Date.now() - 2 * 3600 * 1000) }) + .where(eq(incident.id, incidentId)); + const before = await incidentRow(); + const later = Math.floor(Date.now() / 1000) - 60; + await submit("incident_card_status_page", reportValues(later)); + const row = await incidentRow(); + expect(row?.startedAt.getTime()).toBe(before?.startedAt.getTime()); + expect(row?.statusReportId).not.toBeNull(); + }); + + test("a Started at in the future is refused on the form", async () => { + const future = Math.floor(Date.now() / 1000) + 3600; + const res = await submit("incident_card_status_page", reportValues(future)); + expect(JSON.parse(await res.text())).toEqual({ + response_action: "errors", + errors: { started_at: "Pick a time that isn't in the future." }, + }); + expect((await incidentRow())?.statusReportId).toBeNull(); + }); + + test("a page switch keeps the chosen Started at", async () => { + await signAndPost(app, { + type: "block_actions", + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + view: { + id: "V_FORM", + hash: "h1", + private_metadata: JSON.stringify({ incidentId }), + state: { + values: { started_at: { value: { selected_date_time: 1792098600 } } }, + }, + }, + actions: [ + { action_id: "incident_card_page", selected_option: { value: "1" } }, + ], + }); + const update = slackTestState.calls.find( + (c) => c.method === "views.update", + ); + const view = update?.args.view as { blocks: unknown[] } | undefined; + expect(startedAtOf(view?.blocks ?? [])).toBe(1792098600); + }); + test("a refused status change leaves the severity untouched", async () => { await db .update(incident) @@ -1634,3 +1753,571 @@ describe("publish status update button", () => { expect(String(post?.args.text)).toContain("published a status page update"); }); }); + +describe("status report follow-up after a declare", () => { + const app = createTestApp(); + let workspace: ReturnType; + let incidentId: number; + const startedAt = Math.floor(Date.now() / 1000) - 11 * 3600; + + beforeEach(async () => { + configureSlackDoubles(); + redisStore.clear(); + reportDraftState.calls = []; + reportDraftState.impl = () => + Promise.resolve({ + pageId: 1, + componentIds: [2], + impact: "major_outage", + message: "The dashboard API is returning errors. We are investigating.", + startedAt: new Date(startedAt * 1000).toISOString(), + startedAtSource: "first error in #prod-emergency", + }); + slackTestState.historyImpl = () => + Promise.resolve({ messages: busyChannelHistory }); + workspace = selectWorkspaceSchema.parse( + await db.query.workspace.findFirst({ where: eq(workspaceTable.id, 1) }), + ); + const created = await declareIncident({ + ctx: { workspace, actor: { type: "system", job: "test" } }, + input: { title: `Follow-up ${crypto.randomUUID()}`, severity: "major" }, + }); + incidentId = created.id; + }); + + afterEach(async () => { + const row = await db + .select({ statusReportId: incident.statusReportId }) + .from(incident) + .where(eq(incident.id, incidentId)) + .get(); + await deleteIncidentEvents(incidentId); + await db + .delete(auditLog) + .where( + and( + eq(auditLog.entityType, "incident"), + eq(auditLog.entityId, String(incidentId)), + ), + ); + await db.delete(incident).where(eq(incident.id, incidentId)); + if (row?.statusReportId) { + await db + .delete(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, row.statusReportId)); + await db + .delete(statusReport) + .where(eq(statusReport.id, row.statusReportId)); + } + }); + + function post() { + return postReportFollowUp({ + ctx: { workspace, actor: { type: "system", job: "test" } }, + slack: new WebClient("xoxb-test"), + incidentId, + channelId: "C_INCIDENT", + sourceChannelId: "C_ORIGIN", + }); + } + + function followUp() { + return slackTestState.calls.find( + (c) => c.method === "postMessage" && c.args.channel === "C_INCIDENT", + ); + } + + function buttonValue(actionId: string) { + const blocks = followUp()?.args.blocks as { + type: string; + elements?: { action_id?: string; value?: string }[]; + }[]; + return blocks + .flatMap((b) => b.elements ?? []) + .find((e) => e.action_id === actionId)?.value; + } + + function click(actionId: string) { + return signAndPost(app, { + type: "block_actions", + trigger_id: "trig-follow-up", + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + channel: { id: "C_INCIDENT" }, + message: { ts: "88.8" }, + actions: [{ action_id: actionId, value: buttonValue(actionId) }], + }); + } + + test("posts the drafted report in the incident channel", async () => { + await post(); + const history = slackTestState.calls.find( + (c) => c.method === "conversations.history", + ); + expect(history?.args.channel).toBe("C_ORIGIN"); + expect(reportDraftState.calls[0]?.prompt).toContain("Seeing 500s"); + const text = JSON.stringify(followUp()?.args.blocks); + expect(text).toContain("Customers haven't been told yet"); + expect(text).toContain("The dashboard API is returning errors."); + expect(text).toContain("Google Monitor"); + expect(text).toContain(` { + await db + .update(incident) + .set({ closedAt: new Date() }) + .where(eq(incident.id, incidentId)); + await post(); + expect(followUp()).toBeUndefined(); + }); + + test("a failed draft still offers the form, without a quote", async () => { + reportDraftState.impl = () => Promise.reject(new Error("timeout")); + await post(); + const text = JSON.stringify(followUp()?.args.blocks); + expect(text).toContain("Create status report"); + expect(text).not.toContain("Suggested update"); + }); + + test("Create status report opens the form filled from the draft", async () => { + await post(); + await click("incident_report_follow_up_create"); + const open = slackTestState.calls.find((c) => c.method === "views.open"); + const view = open?.args.view as { + private_metadata: string; + blocks: { + block_id?: string; + element?: { + initial_value?: string; + initial_options?: { value: string }[]; + initial_option?: { value: string }; + initial_date_time?: number; + }; + }[]; + }; + const block = (id: string) => + view.blocks.find((b) => b.block_id === id)?.element; + expect(JSON.parse(view.private_metadata)).toMatchObject({ + incidentId, + channelId: "C_INCIDENT", + followUpTs: "88.8", + }); + expect(block("message")?.initial_value).toBe( + "The dashboard API is returning errors. We are investigating.", + ); + expect(block("components_1")?.initial_options).toEqual([ + expect.objectContaining({ value: "2" }), + ]); + expect(block("impact")?.initial_option?.value).toBe("major_outage"); + expect(block("started_at")?.initial_date_time).toBe(startedAt); + }); + + test("publishing from the follow-up links the report and settles the card", async () => { + const res = await signAndPost(app, { + type: "view_submission", + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + view: { + callback_id: "incident_card_status_page", + private_metadata: JSON.stringify({ + incidentId, + channelId: "C_INCIDENT", + followUpTs: "88.8", + }), + state: { + values: { + page: { incident_card_page: { selected_option: { value: "1" } } }, + title: { value: { value: "Dashboard errors" } }, + status: { value: { selected_option: { value: "investigating" } } }, + started_at: { value: { selected_date_time: startedAt } }, + message: { value: { value: "We are investigating." } }, + }, + }, + }, + }); + expect(await res.text()).toBe(""); + const row = await db + .select() + .from(incident) + .where(eq(incident.id, incidentId)) + .get(); + expect(row?.statusReportId).not.toBeNull(); + expect(row?.startedAt.getTime()).toBe(startedAt * 1000); + const settled = slackTestState.calls.find( + (c) => c.method === "update" && c.args.ts === "88.8", + ); + expect(settled?.args.text).toContain("Status report created by <@U_OWNER>"); + expect(settled?.args.blocks).toEqual([]); + }); + + test("Not needed removes the buttons", async () => { + await post(); + await click("incident_report_follow_up_skip"); + const settled = slackTestState.calls.find( + (c) => c.method === "update" && c.args.ts === "88.8", + ); + expect(settled?.args).toMatchObject({ + channel: "C_INCIDENT", + text: "Status report skipped by <@U_OWNER>.", + blocks: [], + }); + }); +}); + +describe("next steps after an approval", () => { + const app = createTestApp(); + let reportId: number | undefined; + + beforeEach(() => { + configureSlackDoubles(); + redisStore.clear(); + reportId = undefined; + }); + + afterEach(async () => { + if (reportId === undefined) return; + await db + .delete(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, reportId)); + await db.delete(statusReport).where(eq(statusReport.id, reportId)); + }); + + async function approveReport() { + seedCreateStatusReport(); + await signAndPost(app, { + type: "block_actions", + user: { id: "U_OWNER" }, + channel: { id: "C1" }, + message: { ts: "1.2" }, + team: { id: "T_KNOWN" }, + actions: [{ action_id: "approve_pending-123" }], + }); + const update = slackTestState.calls.find( + (c) => c.method === "update" && c.args.ts === "1.2", + ); + const blocks = update?.args.blocks as { + type: string; + elements?: { action_id?: string; value?: string; text?: string }[]; + }[]; + const elements = blocks.flatMap((b) => b.elements ?? []); + reportId = Number(elements.find((e) => e.action_id)?.value); + return { update, elements }; + } + + function clickNext(actionId: string) { + return signAndPost(app, { + type: "block_actions", + trigger_id: "trig-next", + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + channel: { id: "C1" }, + message: { ts: "1.2" }, + actions: [{ action_id: actionId, value: String(reportId) }], + }); + } + + function openedStatus() { + const open = slackTestState.calls.find((c) => c.method === "views.open"); + const view = open?.args.view as { + callback_id: string; + private_metadata: string; + blocks: { + block_id?: string; + element?: { initial_option?: { value: string } }; + }[]; + }; + return { + view, + status: view.blocks.find((b) => b.block_id === "status")?.element + ?.initial_option?.value, + }; + } + + test("an approved report gets the next step and its buttons", async () => { + const { update, elements } = await approveReport(); + expect(JSON.stringify(update?.args.blocks)).toContain( + "Next: post an update when you know more.", + ); + expect(elements.map((e) => e.action_id).filter(Boolean)).toEqual([ + "report_next_post_update", + "report_next_resolve", + ]); + expect(reportId).toBeGreaterThan(0); + }); + + test("Post update opens the update form for that report", async () => { + await approveReport(); + slackTestState.calls = []; + await clickNext("report_next_post_update"); + const { view, status } = openedStatus(); + expect(view.callback_id).toBe("incident_card_status_page"); + expect(JSON.parse(view.private_metadata)).toMatchObject({ + statusReportId: reportId, + channelId: "C1", + }); + expect(status).toBe("investigating"); + }); + + test("Resolve opens the same form preset to resolved", async () => { + await approveReport(); + slackTestState.calls = []; + await clickNext("report_next_resolve"); + expect(openedStatus().status).toBe("resolved"); + }); + + test("a maintenance gets no next step", async () => { + seedCreateMaintenance(); + await signAndPost(app, { + type: "block_actions", + user: { id: "U_OWNER" }, + channel: { id: "C1" }, + message: { ts: "2.2" }, + team: { id: "T_KNOWN" }, + actions: [{ action_id: "approve_maint-001" }], + }); + const update = slackTestState.calls.find( + (c) => c.method === "update" && c.args.ts === "2.2", + ); + expect(update?.args.blocks).toEqual([]); + }); +}); + +describe("link this channel instead", () => { + const app = createTestApp(); + let workspace: ReturnType; + let ids: number[]; + let oldChannel: string; + let sourceChannel: string; + + beforeEach(async () => { + configureSlackDoubles(); + redisStore.clear(); + slackTestState.archiveImpl = () => Promise.resolve({ ok: true }); + workspace = selectWorkspaceSchema.parse( + await db.query.workspace.findFirst({ where: eq(workspaceTable.id, 1) }), + ); + const suffix = crypto.randomUUID().replaceAll("-", "").toUpperCase(); + oldChannel = `CINC${suffix}`; + sourceChannel = `CSRC${suffix}`; + ids = [await declareBound(oldChannel)]; + }); + + afterEach(async () => { + for (const id of ids) { + await deleteIncidentEvents(id); + await db + .delete(auditLog) + .where( + and( + eq(auditLog.entityType, "incident"), + eq(auditLog.entityId, String(id)), + ), + ); + await db.delete(incident).where(eq(incident.id, id)); + } + }); + + async function declareBound(channelId: string) { + const ctx = { workspace, actor: { type: "system" as const, job: "test" } }; + const created = await declareIncident({ + ctx, + input: { title: `Relink ${crypto.randomUUID()}`, severity: "major" }, + }); + await bindIncidentSlackChannel({ + ctx, + input: { id: created.id, teamId: "T_KNOWN", channelId }, + }); + return created.id; + } + + function relink(userId = "U_OWNER") { + const value = ( + relinkNoticeBlocks(ids[0], oldChannel)[1] as { + elements: { value: string }[]; + } + ).elements[0].value; + return signAndPost(app, { + type: "block_actions", + user: { id: userId }, + channel: { id: sourceChannel }, + message: { ts: "5.5" }, + team: { id: "T_KNOWN" }, + actions: [{ action_id: "incident_relink", value }], + }); + } + + async function boundChannel() { + const row = await db + .select({ slackChannelId: incident.slackChannelId }) + .from(incident) + .where(eq(incident.id, ids[0])) + .get(); + return row?.slackChannelId; + } + + test("is only offered from a channel other than the incident's", () => { + expect(offersRelink("C1", "C2")).toBe(true); + expect(offersRelink("G1", "C2")).toBe(true); + expect(offersRelink("D1", "C2")).toBe(false); + expect(offersRelink("C2", "C2")).toBe(false); + }); + + test("moves the incident, pins its card and archives the old channel", async () => { + await relink(); + expect(await boundChannel()).toBe(sourceChannel); + const posted = slackTestState.calls.filter( + (c) => c.method === "postMessage", + ); + expect( + posted.find((c) => c.args.channel === sourceChannel)?.args.text, + ).toContain("Incident: Relink"); + expect( + slackTestState.calls.find((c) => c.method === "pins.add")?.args.channel, + ).toBe(sourceChannel); + expect( + posted.find((c) => c.args.channel === oldChannel)?.args.text, + ).toContain(`moved this incident to <#${sourceChannel}>`); + expect( + slackTestState.calls.find((c) => c.method === "conversations.archive") + ?.args.channel, + ).toBe(oldChannel); + const notice = slackTestState.calls.find( + (c) => c.method === "update" && c.args.ts === "5.5", + ); + expect(notice?.args.text).toContain("now the incident channel"); + }); + + test("a channel already linked to another incident is refused", async () => { + ids.push(await declareBound(sourceChannel)); + await relink(); + expect(await boundChannel()).toBe(oldChannel); + const ephemeral = slackTestState.calls.find( + (c) => c.method === "postEphemeral", + ); + expect(String(ephemeral?.args.text)).toContain(":x:"); + expect( + slackTestState.calls.some((c) => c.method === "conversations.archive"), + ).toBe(false); + }); + + test("a failed archive keeps the move", async () => { + slackTestState.archiveImpl = () => + Promise.reject(new Error("An API error occurred: restricted_action")); + await relink(); + expect(await boundChannel()).toBe(sourceChannel); + expect( + slackTestState.calls.some( + (c) => c.method === "update" && c.args.ts === "5.5", + ), + ).toBe(true); + }); + + test("someone without a linked account is refused", async () => { + slackTestState.usersInfoImpl = () => + Promise.resolve({ ok: true, user: { profile: {} } }); + await relink(`U_STRANGER_${crypto.randomUUID()}`); + expect(await boundChannel()).toBe(oldChannel); + const ephemeral = slackTestState.calls.find( + (c) => c.method === "postEphemeral", + ); + expect(String(ephemeral?.args.text)).toContain( + "Link your openstatus account", + ); + }); +}); + +describe("welcome card buttons", () => { + const app = createTestApp(); + const realFetch = globalThis.fetch; + let posted: { url: string; body: string }[]; + + beforeEach(() => { + configureSlackDoubles(); + redisStore.clear(); + posted = []; + globalThis.fetch = (input, init) => { + posted.push({ url: String(input), body: String(init?.body ?? "") }); + return Promise.resolve(new Response("ok")); + }; + slackTestState.historyImpl = () => + Promise.resolve({ messages: busyChannelHistory }); + }); + + afterEach(() => { + globalThis.fetch = realFetch; + slackTestState.runAgentOverride = null; + }); + + function clickWelcome(actionId: string) { + return signAndPost(app, { + type: "block_actions", + trigger_id: "trig-welcome", + response_url: "https://hooks.slack.test/welcome", + container: { type: "message", is_ephemeral: true }, + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + channel: { id: "C_WELCOME" }, + actions: [{ action_id: actionId }], + }); + } + + test("a click removes the welcome so it isn't clicked twice", async () => { + await clickWelcome("open_publish_status_update"); + expect(posted).toEqual([ + { + url: "https://hooks.slack.test/welcome", + body: JSON.stringify({ delete_original: true }), + }, + ]); + }); + + test("Catch up posts in the channel and runs the agent on its history", async () => { + const seen: { contextNote?: string }[] = []; + slackTestState.runAgentOverride = (options) => { + seen.push(options as { contextNote?: string }); + return Promise.resolve({ + text: "Summary: the dashboard API has been failing since 21:10 UTC.", + toolResults: [], + finishReason: "stop", + stepCount: 1, + hitStepLimit: false, + aborted: false, + }); + }; + await clickWelcome("welcome_catch_up"); + const root = slackTestState.calls.find( + (c) => + c.method === "postMessage" && + String(c.args.text).startsWith(":eyes: Catching up"), + ); + expect(root?.args).toMatchObject({ + channel: "C_WELCOME", + text: ":eyes: Catching up on <#C_WELCOME> for <@U_OWNER>.", + }); + expect(seen).toHaveLength(1); + expect(seen[0].contextNote).toContain("Seeing 500s on the dashboard API"); + const history = slackTestState.calls.find( + (c) => c.method === "conversations.history", + ); + expect(history?.args).toMatchObject({ channel: "C_WELCOME", limit: 50 }); + }); + + test("a quiet channel gets a summary and no card", async () => { + slackTestState.historyImpl = () => Promise.resolve({ messages: [] }); + slackTestState.runAgentOverride = () => + Promise.resolve({ + text: "Nothing is going on here.", + toolResults: [], + finishReason: "stop", + stepCount: 1, + hitStepLimit: false, + aborted: false, + }); + await clickWelcome("welcome_catch_up"); + expect(JSON.stringify(slackTestState.calls).includes("approve_")).toBe( + false, + ); + }); +}); diff --git a/apps/server/src/routes/slack/interactions.ts b/apps/server/src/routes/slack/interactions.ts index 3fb21a743..b77b57552 100644 --- a/apps/server/src/routes/slack/interactions.ts +++ b/apps/server/src/routes/slack/interactions.ts @@ -13,14 +13,18 @@ import { parseActionId, } from "./blocks"; import type { SlackConfig, SlackEnv } from "./config"; -import { consume, get } from "./confirmation-store"; +import { consume, get, recordCreated } from "./confirmation-store"; import type { PendingAction } from "./confirmation-store"; +import { catchUpChannel } from "./handler"; import { OPEN_DECLARE_INCIDENT_ACTION } from "./home"; import { changeStatusPagePage, type IncidentCardAction, openIncidentCardModal, openPublishStatusUpdateModal, + openReportFollowUpModal, + openReportUpdateModal, + settleFollowUp, STATUS_PAGE_CALLBACK, STATUS_PAGE_PAGE_ACTION, submitStatusPage, @@ -37,6 +41,7 @@ import { DECLARE_INCIDENT_FROM_MESSAGE_CALLBACK, NOT_CONNECTED, openDeclareIncidentModal, + type StateValues, submitDeclareIncident, type ViewSubmissionPayload, type ViewSubmissionResponse, @@ -45,9 +50,18 @@ import { afterIncidentTool, bindChannelFromButton, INCIDENT_BIND_ACTION_PREFIX, + INCIDENT_RELINK_ACTION, + relinkIncidentChannel, } from "./incident-slack"; +import { NEXT_STEP_ACTIONS, nextStepBlocks } from "./next-steps"; import { renderToolResult } from "./presenters"; import { executeRegistryAction, getRegistryTool } from "./registry-runner"; +import { loadDraft } from "./report-draft"; +import { + readFollowUpValue, + REPORT_FOLLOW_UP_CREATE, + REPORT_FOLLOW_UP_SKIP, +} from "./report-follow-up"; import { linkAccountUrl, planRequiredMessage, @@ -55,9 +69,14 @@ import { type SlackActor, slackAgentAllowed, } from "./require-slack-member"; -import { respondLater } from "./response-url"; +import { deleteOriginal, respondLater } from "./response-url"; import { toServiceCtx } from "./service-adapter"; -import { PUBLISH_STATUS_UPDATE_ACTION } from "./welcome"; +import { createdRecordFrom } from "./thread-memory"; +import { + CATCH_UP_ACTION, + PUBLISH_STATUS_UPDATE_ACTION, + WELCOME_ACTIONS, +} from "./welcome"; import { resolveWorkspace } from "./workspace-resolver"; const logger = getLogger("api-server"); @@ -93,6 +112,15 @@ async function processIncidentBind(payload: SlackInteractionPayload) { const TIMELINE_UNAVAILABLE = "Couldn't read that message from Slack. Please try again."; +interface WelcomeClick { + response_url?: string; + container?: { is_ephemeral?: boolean }; + team?: { id: string }; + user?: { id: string; team_id?: string }; + channel?: { id: string }; + actions?: Array<{ action_id: string }>; +} + interface SlackShortcutPayload { type: "shortcut" | "message_action" | "block_actions"; callback_id: string; @@ -206,7 +234,12 @@ interface SlackViewActionPayload { trigger_id?: string; channel?: { id: string }; message?: { ts: string }; - view?: { id: string; hash?: string; private_metadata?: string }; + view?: { + id: string; + hash?: string; + private_metadata?: string; + state?: { values: StateValues }; + }; actions: Array<{ action_id: string; value?: string; @@ -214,6 +247,41 @@ interface SlackViewActionPayload { }>; } +/** The drafted report card posted after a declare. */ +async function handleReportFollowUp( + payload: SlackViewActionPayload, + config: SlackConfig, +): Promise { + const teamId = payload.team?.id ?? payload.user.team_id; + const action = payload.actions[0]; + const value = readFollowUpValue(action.value); + const channelId = payload.channel?.id; + const followUpTs = payload.message?.ts; + if (!teamId || !value || !channelId || !followUpTs) return; + if (action.action_id === REPORT_FOLLOW_UP_CREATE) { + if (!payload.trigger_id) return; + await openReportFollowUpModal({ + teamId, + slackUserId: payload.user.id, + triggerId: payload.trigger_id, + incidentId: value.incidentId, + channelId, + followUpTs, + draft: value.draftId ? await loadDraft(value.draftId) : undefined, + config, + }); + return; + } + const resolved = await resolveWorkspace(teamId); + if (!resolved) return; + await settleFollowUp({ + slack: new WebClient(resolved.botToken), + channelId, + ts: followUpTs, + text: `Status report skipped by <@${payload.user.id}>.`, + }); +} + /** Card buttons, awaited since `trigger_id` lives 3s, and in-modal selects. */ async function handleIncidentCardAction( payload: SlackViewActionPayload, @@ -237,6 +305,9 @@ async function handleIncidentCardAction( hash: view.hash, privateMetadata: view.private_metadata, pageId, + startedAt: + view.state?.values.started_at?.value?.selected_date_time ?? + undefined, config, }), { teamId }, @@ -285,6 +356,30 @@ export async function handleSlackInteraction(c: Context) { } const payload = body as SlackInteractionPayload; + const welcome = body as WelcomeClick; + if ( + welcome.response_url && + welcome.container?.is_ephemeral && + WELCOME_ACTIONS.has(welcome.actions?.[0]?.action_id ?? "") + ) { + const responseUrl = welcome.response_url; + runInBackground("welcome-dismiss", () => deleteOriginal(responseUrl), { + teamId: welcome.team?.id, + }); + } + if (payload.actions?.[0]?.action_id === CATCH_UP_ACTION) { + const teamId = welcome.team?.id ?? welcome.user?.team_id; + const channelId = welcome.channel?.id; + const slackUserId = welcome.user?.id; + if (teamId && channelId && slackUserId) { + runInBackground( + "welcome-catch-up", + () => catchUpChannel({ teamId, channelId, slackUserId, config }), + { teamId, channelId }, + ); + } + return c.json({ ok: true }); + } if (payload.actions?.[0]?.action_id === OPEN_DECLARE_INCIDENT_ACTION) { return handleShortcut(c, { ...(body as SlackShortcutPayload), @@ -312,6 +407,42 @@ export async function handleSlackInteraction(c: Context) { } return c.json({ ok: true }); } + if ( + actionId === NEXT_STEP_ACTIONS.postUpdate || + actionId === NEXT_STEP_ACTIONS.resolve + ) { + const action = body as SlackViewActionPayload; + const teamId = action.team?.id ?? action.user.team_id; + const statusReportId = Number(action.actions[0]?.value); + if (teamId && action.trigger_id && Number.isInteger(statusReportId)) { + // Awaited, not backgrounded: `trigger_id` dies with the 3s ack window. + await openReportUpdateModal({ + teamId, + slackUserId: action.user.id, + triggerId: action.trigger_id, + statusReportId, + channelId: action.channel?.id, + resolve: actionId === NEXT_STEP_ACTIONS.resolve, + config, + }).catch((error) => + logger.error("slack next step modal failed", { error, actionId }), + ); + } + return c.json({ ok: true }); + } + if ( + actionId === REPORT_FOLLOW_UP_CREATE || + actionId === REPORT_FOLLOW_UP_SKIP + ) { + await handleReportFollowUp(body as SlackViewActionPayload, config).catch( + (error) => + logger.error("slack report follow-up action failed", { + error, + actionId, + }), + ); + return c.json({ ok: true }); + } if ( actionId && (actionId in CARD_BUTTONS || actionId === STATUS_PAGE_PAGE_ACTION) @@ -328,6 +459,30 @@ export async function handleSlackInteraction(c: Context) { return c.json({ ok: true }); } + if (payload.actions[0].action_id === INCIDENT_RELINK_ACTION) { + const teamId = payload.team?.id; + if (teamId) { + runInBackground( + "incident-relink", + async () => { + const resolved = await resolveWorkspace(teamId); + if (!resolved) return; + await relinkIncidentChannel({ + resolved, + config, + teamId, + slackUserId: payload.user.id, + channelId: payload.channel.id, + messageTs: payload.message.ts, + value: payload.actions[0].value, + }); + }, + { teamId }, + ); + } + return c.json({ ok: true }); + } + if (payload.actions[0].action_id.startsWith(INCIDENT_BIND_ACTION_PREFIX)) { runInBackground("incident-bind", () => processIncidentBind(payload), { teamId: payload.team?.id, @@ -546,9 +701,22 @@ async function runAndPresent(args: { channel: channelId, ts: messageTs, text, - blocks: [], + blocks: + (typeof output === "object" && output !== null + ? nextStepBlocks(tool.name, output, text) + : undefined) ?? [], }); + const created = + typeof output === "object" && output !== null + ? createdRecordFrom(tool.name, output) + : undefined; + if (created) { + await recordCreated(pending.threadTs, created).catch((error) => + logger.warn("slack created record failed", { error, ...created }), + ); + } + if ( typeof output === "object" && output !== null && diff --git a/apps/server/src/routes/slack/model.ts b/apps/server/src/routes/slack/model.ts index a3db8ac80..5d9a7b51c 100644 --- a/apps/server/src/routes/slack/model.ts +++ b/apps/server/src/routes/slack/model.ts @@ -6,3 +6,8 @@ const DEFAULT_MODEL = "anthropic/claude-sonnet-5"; // default rather than being passed through to `generateText`. export const SLACK_AGENT_MODEL = process.env.SLACK_AGENT_MODEL?.trim() || DEFAULT_MODEL; + +const DEFAULT_DRAFT_MODEL = "anthropic/claude-haiku-5"; + +export const SLACK_DRAFT_MODEL = + process.env.SLACK_DRAFT_MODEL?.trim() || DEFAULT_DRAFT_MODEL; diff --git a/apps/server/src/routes/slack/next-steps.test.ts b/apps/server/src/routes/slack/next-steps.test.ts new file mode 100644 index 000000000..d2c606b97 --- /dev/null +++ b/apps/server/src/routes/slack/next-steps.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "@openstatus/test-utils"; + +import { nextStepBlocks } from "./next-steps"; + +function buttons(blocks: ReturnType) { + return (blocks ?? []).flatMap((b) => + b.type === "actions" + ? b.elements.map((e) => + e.type === "button" ? { action: e.action_id, value: e.value } : {}, + ) + : [], + ); +} + +describe("nextStepBlocks", () => { + test("a declared incident: tell customers", () => { + const blocks = nextStepBlocks("declare_incident", { id: 7 }, "Declared."); + expect(JSON.stringify(blocks)).toContain("Next: tell customers."); + expect(buttons(blocks)).toEqual([ + { action: "incident_card_status_page", value: "7" }, + ]); + }); + + test("a report created or updated: post an update or resolve", () => { + const output = { statusReport: { id: 12, status: "investigating" } }; + for (const tool of ["create_status_report", "add_status_report_update"]) { + const blocks = nextStepBlocks(tool, output, "Created."); + expect(JSON.stringify(blocks)).toContain( + "Next: post an update when you know more.", + ); + expect(buttons(blocks)).toEqual([ + { action: "report_next_post_update", value: "12" }, + { action: "report_next_resolve", value: "12" }, + ]); + } + }); + + test("nothing after a resolution or for other tools", () => { + expect( + nextStepBlocks( + "add_status_report_update", + { statusReport: { id: 12, status: "resolved" } }, + "Resolved.", + ), + ).toBeUndefined(); + expect( + nextStepBlocks("create_maintenance", { id: 1 }, "x"), + ).toBeUndefined(); + expect( + nextStepBlocks("declare_incident", { ok: true }, "x"), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/routes/slack/next-steps.ts b/apps/server/src/routes/slack/next-steps.ts new file mode 100644 index 000000000..cc97f42e8 --- /dev/null +++ b/apps/server/src/routes/slack/next-steps.ts @@ -0,0 +1,68 @@ +import { INCIDENT_CARD_ACTIONS } from "@openstatus/services/incident"; +import type { KnownBlock } from "@slack/web-api"; +import { z } from "zod"; + +export const NEXT_STEP_ACTIONS = { + postUpdate: "report_next_post_update", + resolve: "report_next_resolve", +} as const; + +const incidentOutput = z.object({ id: z.number().int() }); +const reportOutput = z.object({ + statusReport: z.object({ id: z.number().int(), status: z.string() }), +}); + +function button(text: string, actionId: string, value: number) { + return { + type: "button" as const, + text: { type: "plain_text" as const, text }, + action_id: actionId, + value: String(value), + }; +} + +function step( + text: string, + sentence: string, + buttons: ReturnType[], +): KnownBlock[] { + return [ + { type: "section", text: { type: "mrkdwn", text: text.slice(0, 3000) } }, + { type: "context", elements: [{ type: "mrkdwn", text: sentence }] }, + { type: "actions", elements: buttons }, + ]; +} + +/** The approved card's result, plus what usually comes next. */ +export function nextStepBlocks( + toolName: string, + output: object, + text: string, +): KnownBlock[] | undefined { + if (toolName === "declare_incident") { + const parsed = incidentOutput.safeParse(output); + if (!parsed.success) return undefined; + return step(text, "Next: tell customers.", [ + button( + "Update status page", + INCIDENT_CARD_ACTIONS.statusPage, + parsed.data.id, + ), + ]); + } + if ( + toolName === "create_status_report" || + toolName === "add_status_report_update" + ) { + const parsed = reportOutput.safeParse(output); + if (!parsed.success || parsed.data.statusReport.status === "resolved") { + return undefined; + } + const id = parsed.data.statusReport.id; + return step(text, "Next: post an update when you know more.", [ + button("Post update", NEXT_STEP_ACTIONS.postUpdate, id), + button("Resolve", NEXT_STEP_ACTIONS.resolve, id), + ]); + } + return undefined; +} diff --git a/apps/server/src/routes/slack/report-draft-model.ts b/apps/server/src/routes/slack/report-draft-model.ts new file mode 100644 index 000000000..23b3ffd4c --- /dev/null +++ b/apps/server/src/routes/slack/report-draft-model.ts @@ -0,0 +1,39 @@ +import { generateText, Output } from "ai"; +import { z } from "zod"; + +import { SLACK_DRAFT_MODEL } from "./model"; + +export const modelDraftSchema = z.object({ + pageId: z.number().int().nullable(), + componentIds: z.array(z.number().int()), + impact: z.enum(["degraded_performance", "partial_outage", "major_outage"]), + message: z.string(), + startedAt: z + .string() + .nullable() + .describe("ISO 8601 UTC time the problem began, if the messages show it."), + startedAtSource: z + .string() + .nullable() + .describe( + "Where that time comes from in a few plain words, e.g. 'first error report in the channel'. No mentions or links.", + ), +}); + +export type ModelDraft = z.infer; + +const DRAFT_TIMEOUT_MS = 20_000; + +export async function generateReportDraft(args: { + system: string; + prompt: string; +}): Promise { + const result = await generateText({ + model: SLACK_DRAFT_MODEL, + system: args.system, + prompt: args.prompt, + output: Output.object({ schema: modelDraftSchema }), + abortSignal: AbortSignal.timeout(DRAFT_TIMEOUT_MS), + }); + return result.output; +} diff --git a/apps/server/src/routes/slack/report-draft.test.ts b/apps/server/src/routes/slack/report-draft.test.ts new file mode 100644 index 000000000..96cd2877b --- /dev/null +++ b/apps/server/src/routes/slack/report-draft.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, test } from "@openstatus/test-utils"; + +import { reportDraftState } from "@/libs/test/doubles/report-draft-model.mock"; +import { busyChannelHistory } from "@/libs/test/slack-incident-fixtures"; + +import { + type DraftCandidates, + draftStatusReport, + loadDraft, + storeDraft, + validateDraft, +} from "./report-draft"; + +const candidates: DraftCandidates = { + pages: [ + { id: 1, title: "Public" }, + { id: 2, title: "Internal" }, + ], + components: [ + { id: 10, pageId: 1, name: "API", failing: false }, + { id: 11, pageId: 1, name: "Dashboard", failing: false }, + { id: 20, pageId: 2, name: "Workers", failing: false }, + ], +}; + +const incidentStartedAt = new Date("2026-10-16T08:30:00Z"); +const now = new Date("2026-10-16T08:35:00Z"); + +const modelDraft = { + pageId: 1, + componentIds: [11, 99, 20, 11], + impact: "partial_outage" as const, + message: " The dashboard is returning errors. We are investigating. ", + startedAt: "2026-10-15T21:10:00Z", + startedAtSource: "first error reported in the channel", +}; + +describe("validateDraft", () => { + test("drops unknown and other-page components and duplicates", () => { + const draft = validateDraft(modelDraft, candidates, incidentStartedAt, now); + expect(draft).toEqual({ + pageId: 1, + componentIds: [11], + impact: "partial_outage", + message: "The dashboard is returning errors. We are investigating.", + startedAt: Math.floor(Date.parse("2026-10-15T21:10:00Z") / 1000), + startedAtSource: "first error reported in the channel", + }); + }); + + test("an unknown page falls back to the failing component's page", () => { + const draft = validateDraft( + { ...modelDraft, pageId: 42, componentIds: [] }, + { + ...candidates, + components: candidates.components.map((c) => ({ + ...c, + failing: c.id === 20, + })), + }, + incidentStartedAt, + now, + ); + expect(draft?.pageId).toBe(2); + }); + + test("keeps a start only when it is earlier, past and recent", () => { + const at = (startedAt: string | null) => + validateDraft( + { ...modelDraft, startedAt }, + candidates, + incidentStartedAt, + now, + )?.startedAt; + expect(at("2026-10-16T09:00:00Z")).toBeUndefined(); + expect(at("2026-08-01T00:00:00Z")).toBeUndefined(); + expect(at("not a date")).toBeUndefined(); + expect(at(null)).toBeUndefined(); + }); + + test("no pages, no draft", () => { + expect( + validateDraft( + modelDraft, + { pages: [], components: [] }, + incidentStartedAt, + ), + ).toBeUndefined(); + }); +}); + +describe("draftStatusReport", () => { + beforeEach(() => { + reportDraftState.calls = []; + }); + + const incident = { + title: "Dashboard errors", + summary: "Staging rename dropped grants", + startedAt: incidentStartedAt, + }; + + test("sends the conversation and the pages, marking failing monitors", async () => { + reportDraftState.impl = () => Promise.resolve(modelDraft); + await draftStatusReport({ + incident, + sourceMessages: [...busyChannelHistory].reverse(), + candidates: { + ...candidates, + components: candidates.components.map((c) => ({ + ...c, + failing: c.id === 10, + })), + }, + }); + const prompt = reportDraftState.calls[0]?.prompt ?? ""; + expect(prompt).toContain("Incident: Dashboard errors"); + expect(prompt).toContain("- 10: API (monitor failing)"); + expect(prompt).toContain("[2026-10-15 21:10 UTC] <@U_ENGINEER>"); + }); + + test("with no failing monitors, only the conversation points at components", async () => { + reportDraftState.impl = () => Promise.resolve(modelDraft); + await draftStatusReport({ incident, sourceMessages: [], candidates }); + const prompt = reportDraftState.calls[0]?.prompt ?? ""; + expect(prompt).not.toContain("monitor failing"); + expect(prompt).toContain("(no channel messages)"); + }); + + test("a model failure gives no draft instead of throwing", async () => { + reportDraftState.impl = () => Promise.reject(new Error("timeout")); + expect( + await draftStatusReport({ incident, sourceMessages: [], candidates }), + ).toBeUndefined(); + }); + + test("a workspace without pages never calls the model", async () => { + expect( + await draftStatusReport({ + incident, + sourceMessages: [], + candidates: { pages: [], components: [] }, + }), + ).toBeUndefined(); + expect(reportDraftState.calls).toHaveLength(0); + }); +}); + +describe("stored drafts", () => { + test("round-trip by id", async () => { + const draft = { + pageId: 1, + componentIds: [11], + impact: "major_outage" as const, + message: "Down.", + }; + const id = await storeDraft(draft); + expect(await loadDraft(id)).toEqual(draft); + expect(await loadDraft("missing")).toBeUndefined(); + }); +}); diff --git a/apps/server/src/routes/slack/report-draft.ts b/apps/server/src/routes/slack/report-draft.ts new file mode 100644 index 000000000..f6621c6b5 --- /dev/null +++ b/apps/server/src/routes/slack/report-draft.ts @@ -0,0 +1,178 @@ +import { getLogger } from "@logtape/logtape"; +import type { ServiceContext } from "@openstatus/services"; +import { listPages } from "@openstatus/services/page"; +import { listPageComponents } from "@openstatus/services/page-component"; +import { nanoid } from "nanoid"; +import { z } from "zod"; + +import { redis } from "@/libs/clients"; + +import type { ChannelMessage } from "./channel-context"; +import { generateReportDraft } from "./report-draft-model"; +import { formatSlackLine } from "./thread-messages"; + +const logger = getLogger(["api-server", "slack", "report-draft"]); + +const DRAFT_PREFIX = "slack:report-draft:"; +const DRAFT_TTL_SECONDS = 30 * 24 * 60 * 60; +const MAX_BACKDATE_MS = 30 * 24 * 60 * 60 * 1000; + +const reportDraftSchema = z.object({ + pageId: z.number().int(), + componentIds: z.array(z.number().int()), + impact: z.enum(["degraded_performance", "partial_outage", "major_outage"]), + message: z.string(), + startedAt: z.number().int().optional(), + startedAtSource: z.string().optional(), +}); + +export type ReportDraft = z.infer; + +export type DraftCandidates = { + pages: { id: number; title: string }[]; + components: { + id: number; + pageId: number; + name: string; + failing: boolean; + }[]; +}; + +export async function loadDraftCandidates( + ctx: ServiceContext, +): Promise { + const [pages, components] = await Promise.all([ + listPages({ ctx, input: { order: "asc" } }), + listPageComponents({ ctx, input: { order: "asc" } }), + ]); + return { + pages: pages.map((p) => ({ id: p.id, title: p.title })), + components: components.map((c) => ({ + id: c.id, + pageId: c.pageId, + name: c.name, + failing: !!c.monitor && c.monitor.status !== "active", + })), + }; +} + +function buildPrompt(args: { + incident: { title: string; summary: string | null; startedAt: Date }; + sourceMessages: ChannelMessage[]; + candidates: DraftCandidates; +}): string { + const { incident, sourceMessages, candidates } = args; + const pages = candidates.pages + .map((p) => { + const components = candidates.components + .filter((c) => c.pageId === p.id) + .map( + (c) => + ` - ${c.id}: ${c.name}${c.failing ? " (monitor failing)" : ""}`, + ); + return [`- page ${p.id}: ${p.title}`, ...components].join("\n"); + }) + .join("\n"); + const conversation = sourceMessages.length + ? sourceMessages.map(formatSlackLine).join("\n") + : "(no channel messages)"; + return `Incident: ${incident.title} +Summary: ${incident.summary ?? "(none)"} +Declared start: ${incident.startedAt.toISOString()} + +Status pages and their components: +${pages} + +Channel messages before the incident was declared (oldest first, UTC): +${conversation}`; +} + +const SYSTEM = `You draft the first public status report for an incident that was just declared. +- message: 1-3 short, factual sentences for customers: what is affected and that the team is investigating. No speculation about causes, no internal names, ids or people. +- pageId and componentIds: pick only from the list. Prefer components whose monitor is failing; add others only when the incident or messages clearly name them. Use an empty list when unsure, and null pageId when no page fits. +- impact: major_outage when it is down, degraded_performance when slow, partial_outage otherwise. +- startedAt: the earliest time the messages show users were affected, as ISO 8601 UTC, or null. startedAtSource says where it comes from in a few words.`; + +/** Never throws: a failed draft is a card without a quote. */ +export async function draftStatusReport(args: { + incident: { title: string; summary: string | null; startedAt: Date }; + sourceMessages: ChannelMessage[]; + candidates: DraftCandidates; +}): Promise { + const { incident, sourceMessages, candidates } = args; + if (!candidates.pages.length) return undefined; + try { + const draft = await generateReportDraft({ + system: SYSTEM, + prompt: buildPrompt({ incident, sourceMessages, candidates }), + }); + return validateDraft(draft, candidates, incident.startedAt); + } catch (error) { + logger.warn("slack report draft failed", { error }); + return undefined; + } +} + +export function validateDraft( + draft: { + pageId: number | null; + componentIds: number[]; + impact: ReportDraft["impact"]; + message: string; + startedAt: string | null; + startedAtSource: string | null; + }, + candidates: DraftCandidates, + incidentStartedAt: Date, + now = new Date(), +): ReportDraft | undefined { + const failingPage = candidates.components.find((c) => c.failing)?.pageId; + const pageId = + candidates.pages.find((p) => p.id === draft.pageId)?.id ?? + failingPage ?? + candidates.pages[0]?.id; + if (pageId === undefined) return undefined; + const onPage = new Set( + candidates.components.filter((c) => c.pageId === pageId).map((c) => c.id), + ); + const startedAt = draft.startedAt ? new Date(draft.startedAt) : undefined; + const backdated = + startedAt && + !Number.isNaN(startedAt.getTime()) && + startedAt < incidentStartedAt && + startedAt <= now && + now.getTime() - startedAt.getTime() <= MAX_BACKDATE_MS; + return { + pageId, + componentIds: [...new Set(draft.componentIds)].filter((id) => + onPage.has(id), + ), + impact: draft.impact, + message: draft.message.trim(), + ...(backdated + ? { + startedAt: Math.floor(startedAt.getTime() / 1000), + ...(draft.startedAtSource + ? { startedAtSource: draft.startedAtSource } + : {}), + } + : {}), + }; +} + +export async function storeDraft(draft: ReportDraft): Promise { + const id = nanoid(); + await redis.set(`${DRAFT_PREFIX}${id}`, JSON.stringify(draft), { + ex: DRAFT_TTL_SECONDS, + }); + return id; +} + +export async function loadDraft(id: string): Promise { + const raw = await redis.get(`${DRAFT_PREFIX}${id}`); + if (!raw) return undefined; + const parsed = reportDraftSchema.safeParse( + typeof raw === "string" ? JSON.parse(raw) : raw, + ); + return parsed.success ? parsed.data : undefined; +} diff --git a/apps/server/src/routes/slack/report-follow-up.ts b/apps/server/src/routes/slack/report-follow-up.ts new file mode 100644 index 000000000..574a71e1f --- /dev/null +++ b/apps/server/src/routes/slack/report-follow-up.ts @@ -0,0 +1,150 @@ +import { getLogger } from "@logtape/logtape"; +import type { ServiceContext } from "@openstatus/services"; +import { escapeMrkdwn, getIncident } from "@openstatus/services/incident"; +import type { KnownBlock, WebClient } from "@slack/web-api"; +import { z } from "zod"; + +import { type ChannelMessage, fetchChannelHistory } from "./channel-context"; +import { + type DraftCandidates, + draftStatusReport, + loadDraftCandidates, + type ReportDraft, + storeDraft, +} from "./report-draft"; + +const logger = getLogger(["api-server", "slack", "report-follow-up"]); + +export const REPORT_FOLLOW_UP_CREATE = "incident_report_follow_up_create"; +export const REPORT_FOLLOW_UP_SKIP = "incident_report_follow_up_skip"; +export const REPORT_FOLLOW_UP_TEXT = + "Customers haven't been told yet. Create a status report?"; + +const SOURCE_MESSAGES = 50; +const QUOTE_MAX = 2_500; + +export const followUpValueSchema = z.object({ + incidentId: z.number().int(), + draftId: z.string().optional(), +}); +export type FollowUpValue = z.infer; + +export function readFollowUpValue(raw: string | undefined) { + try { + return followUpValueSchema.safeParse(JSON.parse(raw ?? "")).data; + } catch { + return undefined; + } +} + +function quote(text: string): string { + return `>${escapeMrkdwn(text.slice(0, QUOTE_MAX)).replaceAll("\n", "\n>")}`; +} + +export function buildFollowUpBlocks(args: { + value: FollowUpValue; + draft?: ReportDraft; + candidates?: DraftCandidates; +}): KnownBlock[] { + const { value, draft, candidates } = args; + const details: string[] = []; + if (draft) { + const page = candidates?.pages.find((p) => p.id === draft.pageId); + if (page) details.push(`*Page:* ${escapeMrkdwn(page.title)}`); + const names = (candidates?.components ?? []) + .filter((c) => draft.componentIds.includes(c.id)) + .map((c) => escapeMrkdwn(c.name)); + if (names.length) details.push(`*Components:* ${names.join(", ")}`); + if (draft.startedAt) { + const iso = new Date(draft.startedAt * 1000).toISOString(); + const from = draft.startedAtSource + ? ` (${escapeMrkdwn(draft.startedAtSource)})` + : ""; + details.push( + `*Started at:* ${from}`, + ); + } + } + const json = JSON.stringify(value); + return [ + { + type: "section", + text: { type: "mrkdwn", text: `:mega: ${REPORT_FOLLOW_UP_TEXT}` }, + }, + ...(draft?.message + ? [ + { + type: "section" as const, + text: { + type: "mrkdwn" as const, + text: `Suggested update:\n${quote(draft.message)}`, + }, + }, + ] + : []), + ...(details.length + ? [ + { + type: "context" as const, + elements: [{ type: "mrkdwn" as const, text: details.join(" ") }], + }, + ] + : []), + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Create status report" }, + action_id: REPORT_FOLLOW_UP_CREATE, + value: json, + }, + { + type: "button", + text: { type: "plain_text", text: "Not needed" }, + action_id: REPORT_FOLLOW_UP_SKIP, + value: json, + }, + ], + }, + ]; +} + +/** After a declare: offer a drafted status report in the incident channel. */ +export async function postReportFollowUp(args: { + ctx: ServiceContext; + slack: WebClient; + incidentId: number; + channelId: string; + sourceChannelId?: string; +}): Promise { + const { ctx, slack, incidentId, channelId, sourceChannelId } = args; + const incident = await getIncident({ ctx, input: { id: incidentId } }); + if (!incident || incident.statusReport || incident.closedAt) return; + + const sourceMessages: ChannelMessage[] = sourceChannelId + ? await fetchChannelHistory(slack, sourceChannelId, { + limit: SOURCE_MESSAGES, + }).catch((error) => { + logger.info("slack follow-up could not read the source channel", { + error, + sourceChannelId, + }); + return []; + }) + : []; + const candidates = await loadDraftCandidates(ctx); + const draft = await draftStatusReport({ + incident, + sourceMessages, + candidates, + }); + const draftId = draft ? await storeDraft(draft) : undefined; + const value = { incidentId, ...(draftId ? { draftId } : {}) }; + + await slack.chat.postMessage({ + channel: channelId, + text: REPORT_FOLLOW_UP_TEXT, + blocks: buildFollowUpBlocks({ value, draft, candidates }), + }); +} diff --git a/apps/server/src/routes/slack/response-url.ts b/apps/server/src/routes/slack/response-url.ts index db8c39f64..9a01187ce 100644 --- a/apps/server/src/routes/slack/response-url.ts +++ b/apps/server/src/routes/slack/response-url.ts @@ -28,3 +28,15 @@ export async function respondLater( }); } } + +/** Removes the ephemeral message a button was clicked on. */ +export async function deleteOriginal(responseUrl: string): Promise { + const res = await fetch(responseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ delete_original: true }), + }); + if (!res.ok) { + logger.warn("slack response_url delete failed", { status: res.status }); + } +} diff --git a/apps/server/src/routes/slack/slack-links.test.ts b/apps/server/src/routes/slack/slack-links.test.ts new file mode 100644 index 000000000..caa771529 --- /dev/null +++ b/apps/server/src/routes/slack/slack-links.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, test } from "@openstatus/test-utils"; +// The double stands in for @slack/web-api via the test import map. +import { WebClient } from "@slack/web-api"; + +import { slackTestState } from "@/libs/test/doubles/slack-test-state"; +import { + FIXTURE_CHANNEL_ID, + FIXTURE_STARTED_TS, + firstErrorPermalink, +} from "@/libs/test/slack-incident-fixtures"; + +import { + findPermalinks, + linkedMessagesNote, + parsePermalink, + readPermalink, +} from "./slack-links"; + +function platformError(code: string): Error { + return Object.assign(new Error(`An API error occurred: ${code}`), { + code: "slack_webapi_platform_error", + data: { ok: false, error: code }, + }); +} + +describe("parsePermalink", () => { + test("a plain link", () => { + expect(parsePermalink(firstErrorPermalink)).toEqual({ + url: firstErrorPermalink, + channel: FIXTURE_CHANNEL_ID, + ts: FIXTURE_STARTED_TS, + }); + }); + + test("a thread reply link carries its thread", () => { + expect( + parsePermalink( + "https://acme.slack.com/archives/C123/p1792139400000200?thread_ts=1792139100.000100&cid=C123", + ), + ).toMatchObject({ + channel: "C123", + ts: "1792139400.000200", + threadTs: "1792139100.000100", + }); + }); + + test("ignores unrelated query parameters", () => { + expect( + parsePermalink( + "https://acme.slack.com/archives/G42/p1792139400000200?utm_source=x", + ), + ).toEqual({ + url: "https://acme.slack.com/archives/G42/p1792139400000200?utm_source=x", + channel: "G42", + ts: "1792139400.000200", + }); + }); + + test("rejects anything else", () => { + expect( + parsePermalink("https://example.com/archives/C1/p1"), + ).toBeUndefined(); + expect( + parsePermalink("https://acme.slack.com/archives/C1/p123"), + ).toBeUndefined(); + expect(parsePermalink("https://grafana.example.com/d/x")).toBeUndefined(); + }); +}); + +describe("findPermalinks", () => { + test("finds links in Slack's markup, deduplicated and capped", () => { + const link = (n: number) => + `https://acme.slack.com/archives/C1/p179213940000020${n}`; + const text = `see <${link(1)}|here> and <${link(1)}> ${[2, 3, 4, 5, 6, 7].map(link).join(" ")}`; + const found = findPermalinks(text); + expect(found).toHaveLength(5); + expect(found.map((l) => l.ts)).toEqual([ + "1792139400.000201", + "1792139400.000202", + "1792139400.000203", + "1792139400.000204", + "1792139400.000205", + ]); + }); +}); + +describe("readPermalink", () => { + const slack = new WebClient("xoxb-test"); + const link = { + url: firstErrorPermalink, + channel: FIXTURE_CHANNEL_ID, + ts: FIXTURE_STARTED_TS, + }; + + beforeEach(() => { + slackTestState.calls = []; + slackTestState.conversationsInfoImpl = () => + Promise.resolve({ ok: true, channel: { is_private: false } }); + slackTestState.membersImpl = () => + Promise.resolve({ ok: true, members: ["U1"] }); + slackTestState.historyImpl = () => + Promise.resolve({ + messages: [ + { user: "U_ENGINEER", text: "500s", ts: FIXTURE_STARTED_TS }, + ], + }); + }); + + test("a public channel: time, author and text", async () => { + expect(await readPermalink({ slack, link, requesterId: "U9" })).toEqual({ + ok: true, + link, + time: "2026-10-15T21:10:00.000Z", + author: "U_ENGINEER", + text: "500s", + }); + expect( + slackTestState.calls.find((c) => c.method === "conversations.history") + ?.args, + ).toMatchObject({ + channel: FIXTURE_CHANNEL_ID, + oldest: FIXTURE_STARTED_TS, + latest: FIXTURE_STARTED_TS, + inclusive: true, + limit: 1, + }); + expect( + slackTestState.calls.some((c) => c.method === "conversations.members"), + ).toBe(false); + }); + + test("a private channel the requester is in", async () => { + slackTestState.conversationsInfoImpl = () => + Promise.resolve({ ok: true, channel: { is_private: true } }); + slackTestState.membersImpl = (args) => + Promise.resolve( + args.cursor + ? { ok: true, members: ["U1"] } + : { + ok: true, + members: ["U2"], + response_metadata: { next_cursor: "next" }, + }, + ); + const result = await readPermalink({ slack, link, requesterId: "U1" }); + expect(result.ok).toBe(true); + }); + + test("a private channel the requester isn't in is refused", async () => { + slackTestState.conversationsInfoImpl = () => + Promise.resolve({ ok: true, channel: { is_private: true } }); + const result = await readPermalink({ slack, link, requesterId: "U9" }); + expect(result).toEqual({ ok: false, link, reason: "not_member" }); + expect( + slackTestState.calls.some((c) => c.method === "conversations.history"), + ).toBe(false); + }); + + test("a channel the bot isn't in", async () => { + slackTestState.historyImpl = () => + Promise.reject(platformError("not_in_channel")); + expect(await readPermalink({ slack, link, requesterId: "U1" })).toEqual({ + ok: false, + link, + reason: "bot_not_in_channel", + }); + }); + + test("a deleted message", async () => { + slackTestState.historyImpl = () => + Promise.resolve({ messages: [{ user: "U1", text: "other", ts: "1.0" }] }); + expect(await readPermalink({ slack, link, requesterId: "U1" })).toEqual({ + ok: false, + link, + reason: "not_found", + }); + }); + + test("a thread reply is read from its thread", async () => { + let repliesArgs: Record | undefined; + slackTestState.repliesImpl = (args) => { + repliesArgs = args; + return Promise.resolve({ + messages: [{ user: "U2", text: "reply", ts: "1792139400.000200" }], + }); + }; + const result = await readPermalink({ + slack, + link: { + url: "x", + channel: "C1", + ts: "1792139400.000200", + threadTs: "1792139100.000100", + }, + requesterId: "U1", + }); + expect(result.ok && result.text).toBe("reply"); + expect(repliesArgs).toMatchObject({ + ts: "1792139100.000100", + oldest: "1792139400.000200", + }); + }); + + test("the note relays refusals and content", async () => { + const note = await linkedMessagesNote({ + slack, + text: `started here <${firstErrorPermalink}>`, + requesterId: "U1", + }); + expect(note).toContain("2026-10-15 21:10 UTC"); + expect(note).toContain("<@U_ENGINEER>: 500s"); + expect( + await linkedMessagesNote({ slack, text: "no links", requesterId: "U1" }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/routes/slack/slack-links.ts b/apps/server/src/routes/slack/slack-links.ts new file mode 100644 index 000000000..e964df07c --- /dev/null +++ b/apps/server/src/routes/slack/slack-links.ts @@ -0,0 +1,215 @@ +import { getLogger } from "@logtape/logtape"; +import type { WebClient } from "@slack/web-api"; +import { type Tool, tool } from "ai"; +import { z } from "zod"; + +import { formatSlackTime, slackTsToDate } from "./thread-messages"; + +const logger = getLogger("api-server"); + +const PERMALINK = + /https?:\/\/[\w-]+\.slack\.com\/archives\/([CDG][A-Z0-9]+)\/p(\d{16})(\?[^\s>|]*)?/g; +const MAX_LINKS = 5; +const MAX_TEXT_CHARS = 2_000; +const MAX_MEMBER_PAGES = 10; + +export type Permalink = { + url: string; + channel: string; + ts: string; + threadTs?: string; +}; + +export type LinkedMessage = + | { + ok: true; + link: Permalink; + time: string; + author: string; + text: string; + } + | { + ok: false; + link: Permalink; + reason: "not_member" | "bot_not_in_channel" | "not_found"; + }; + +function toTs(digits: string): string { + return `${digits.slice(0, 10)}.${digits.slice(10)}`; +} + +export function parsePermalink(url: string): Permalink | undefined { + const match = new RegExp(PERMALINK.source).exec(url); + if (!match) return undefined; + const threadTs = new URLSearchParams(match[3] ?? "").get("thread_ts"); + return { + url: match[0], + channel: match[1], + ts: toTs(match[2]), + ...(threadTs && /^\d{10}\.\d{6}$/.test(threadTs) ? { threadTs } : {}), + }; +} + +export function findPermalinks(text: string, max = MAX_LINKS): Permalink[] { + const found: Permalink[] = []; + const seen = new Set(); + for (const match of text.matchAll(PERMALINK)) { + const link = parsePermalink(match[0]); + if (!link || seen.has(`${link.channel}:${link.ts}`)) continue; + seen.add(`${link.channel}:${link.ts}`); + found.push(link); + if (found.length >= max) break; + } + return found; +} + +function slackError(err: Error): string | undefined { + const parsed = z + .object({ data: z.object({ error: z.string() }) }) + .safeParse(err); + return parsed.success ? parsed.data.data.error : undefined; +} + +async function isMember( + slack: WebClient, + channel: string, + userId: string, +): Promise { + let cursor: string | undefined; + for (let page = 0; page < MAX_MEMBER_PAGES; page++) { + const res = await slack.conversations.members({ + channel, + limit: 1000, + cursor, + }); + if (res.members?.includes(userId)) return true; + cursor = res.response_metadata?.next_cursor || undefined; + if (!cursor) return false; + } + return false; +} + +async function fetchMessage(slack: WebClient, link: Permalink) { + const range = { oldest: link.ts, latest: link.ts, inclusive: true, limit: 1 }; + const res = + link.threadTs && link.threadTs !== link.ts + ? await slack.conversations.replies({ + channel: link.channel, + ts: link.threadTs, + ...range, + }) + : await slack.conversations.history({ channel: link.channel, ...range }); + return res.messages?.find((m) => m.ts === link.ts); +} + +/** + * The bot reads with its own token, so a private channel is only shared with + * someone who is in it themselves. + */ +export async function readPermalink(args: { + slack: WebClient; + link: Permalink; + requesterId: string; +}): Promise { + const { slack, link, requesterId } = args; + try { + const info = await slack.conversations.info({ channel: link.channel }); + const restricted = + info.channel?.is_private || info.channel?.is_im || info.channel?.is_mpim; + if (restricted && !(await isMember(slack, link.channel, requesterId))) { + return { ok: false, link, reason: "not_member" }; + } + const message = await fetchMessage(slack, link); + const date = slackTsToDate(link.ts); + if (!message?.text || !date) + return { ok: false, link, reason: "not_found" }; + return { + ok: true, + link, + time: date.toISOString(), + author: message.user ?? message.bot_id ?? "unknown", + text: message.text.slice(0, MAX_TEXT_CHARS), + }; + } catch (err) { + const code = err instanceof Error ? slackError(err) : undefined; + if (code === "not_in_channel" || code === "channel_not_found") { + return { ok: false, link, reason: "bot_not_in_channel" }; + } + if (code === "message_not_found" || code === "thread_not_found") { + return { ok: false, link, reason: "not_found" }; + } + logger.warn("slack permalink read failed", { + error: err, + channel: link.channel, + }); + return { ok: false, link, reason: "not_found" }; + } +} + +const REFUSALS: Record< + Extract["reason"], + (channel: string) => string +> = { + not_member: () => "I can't share messages from a channel you're not in.", + bot_not_in_channel: (channel) => + `I'm not in <#${channel}>. Invite me there with \`/invite @openstatus\` to read that link.`, + not_found: () => "That message no longer exists or I can't find it.", +}; + +export function describeLinkedMessage(message: LinkedMessage): string { + if (!message.ok) { + return `${message.link.url} — ${REFUSALS[message.reason](message.link.channel)}`; + } + const time = formatSlackTime(message.link.ts) ?? message.time; + return `${message.link.url} — <#${message.link.channel}>, ${time} (${message.time}), <@${message.author}>: ${message.text}`; +} + +export async function linkedMessagesNote(args: { + slack: WebClient; + text: string | undefined; + requesterId: string | undefined; +}): Promise { + if (!args.text || !args.requesterId) return undefined; + const links = findPermalinks(args.text); + if (!links.length) return undefined; + const requesterId = args.requesterId; + const messages = await Promise.all( + links.map((link) => + readPermalink({ slack: args.slack, link, requesterId }), + ), + ); + return `Slack messages linked in the user's message (relay a refusal as written, never guess the content): +${messages.map((m) => `- ${describeLinkedMessage(m)}`).join("\n")}`; +} + +export function slackLinkTools(args: { + slack: WebClient; + requesterId: string; +}): Record { + return { + read_slack_message: tool({ + description: + "Read one Slack message from its permalink (https://.slack.com/archives/C…/p…): its time, author and text. Use it for links you find in channel history or the thread; links in the user's latest message are already read for you.", + inputSchema: z.object({ + permalink: z.string().describe("The Slack message link."), + }), + execute: async ({ permalink }) => { + const link = parsePermalink(permalink); + if (!link) return { error: "not a Slack message link" }; + const message = await readPermalink({ + slack: args.slack, + link, + requesterId: args.requesterId, + }); + return message.ok + ? { + time: message.time, + author: message.author, + text: message.text, + channel: link.channel, + } + : { error: REFUSALS[message.reason](link.channel) }; + }, + }), + }; +} diff --git a/apps/server/src/routes/slack/system-prompt.test.ts b/apps/server/src/routes/slack/system-prompt.test.ts index fb3f29d59..fb8dfe75c 100644 --- a/apps/server/src/routes/slack/system-prompt.test.ts +++ b/apps/server/src/routes/slack/system-prompt.test.ts @@ -20,6 +20,25 @@ describe("buildSystemPrompt", () => { expect(promptDate.getTime()).toBeLessThanOrEqual(after.getTime()); }); + test("explains the context notes it gets in Slack", () => { + const prompt = buildSystemPrompt("Acme Corp"); + expect(prompt).toContain("[2026-10-16 08:30 UTC] <@U123>"); + expect(prompt).toContain("read_slack_channel with `before`"); + expect(prompt).toContain("read_slack_message"); + expect(prompt).toContain("approved cards already created in this thread"); + expect(prompt).toContain( + "pass that time as `date` to create_status_report", + ); + }); + + test("never denies an incident without naming what exists", () => { + const prompt = buildSystemPrompt("Acme Corp"); + expect(prompt).toContain( + "Check both (list_incidents and list_status_reports)", + ); + expect(prompt).toContain("Never say there is no incident without naming"); + }); + test("includes the workspace name", () => { const prompt = buildSystemPrompt("Acme Corp"); expect(prompt).toContain('workspace "Acme Corp"'); diff --git a/apps/server/src/routes/slack/system-prompt.ts b/apps/server/src/routes/slack/system-prompt.ts index b2acbc120..a8124250b 100644 --- a/apps/server/src/routes/slack/system-prompt.ts +++ b/apps/server/src/routes/slack/system-prompt.ts @@ -11,7 +11,17 @@ Managed incidents (internal, never published): - "mitigated", "the bleeding stopped" -> set_incident_status mitigated; "false alarm", "declared by mistake" -> set_incident_status canceled (closes it for good). - In an incident channel (see the note below when there is one), notes and updates belong to that incident: use its id without asking. - Postmortems: get_postmortem reads one; draft_postmortem saves a draft you wrote from get_incident and the channel (resolved incidents only, never invent facts); approve_postmortem signs it off (approval card) and by default closes the incident. For a full draft from the whole channel, suggest \`/openstatus incident postmortem\`. -- Before referencing a managed incident, call list_incidents. Severity: critical = major outage or data loss, major = significant degradation, minor = limited impact.`; +- Before referencing a managed incident, call list_incidents. Severity: critical = major outage or data loss, major = significant degradation, minor = limited impact. +- "The incident" may mean a managed incident or an open status report. Check both (list_incidents and list_status_reports) before answering. Never say there is no incident without naming what does exist: "No managed incident, but status report 'API errors' is open (investigating)."`; + +const CONTEXT_SECTION = ` + +Context you get with each message: +- Messages are prefixed with their author and UTC time: "[2026-10-16 08:30 UTC] <@U123>: …". Use these times; never guess a time from how Slack displays it. +- In a channel, a note lists the channel's recent messages before this conversation, oldest first. It is background for the request, not a request in itself. For older history, call read_slack_channel with \`before\` set to the oldest ts you have. +- Slack message links in the user's message are already read for you and listed in a note with their time and author; for links found elsewhere, call read_slack_message. When a link is refused, relay the refusal and don't guess its content. +- A note may list what approved cards already created in this thread. Those exist: use their ids and never claim they don't. +- When the context shows when a problem started (an earlier message, a linked message, "since 11pm yesterday"), pass that time as \`date\` to create_status_report so the report starts then, and say in one line where the time came from.`; export function buildSystemPrompt( workspaceName: string, @@ -136,5 +146,5 @@ Maintenance scheduling: - Parse natural language dates into ISO 8601 format. Convert relative dates like "next Friday from 2-3 PM" into proper ISO 8601 timestamps. - If the user doesn't specify a timezone, default to UTC and mention that in your response. - The "from" time must be before the "to" time. -- Write a professional maintenance message describing what will happen during the window.${INCIDENT_SECTION}${contextNote ? `\n\n${contextNote.trim()}` : ""}`; +- Write a professional maintenance message describing what will happen during the window.${INCIDENT_SECTION}${CONTEXT_SECTION}${contextNote ? `\n\n${contextNote.trim()}` : ""}`; } diff --git a/apps/server/src/routes/slack/thread-memory.test.ts b/apps/server/src/routes/slack/thread-memory.test.ts new file mode 100644 index 000000000..58504ec2e --- /dev/null +++ b/apps/server/src/routes/slack/thread-memory.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "@openstatus/test-utils"; + +import { createdNote, createdRecordFrom } from "./thread-memory"; + +describe("createdRecordFrom", () => { + test("reads the report out of create and update outputs", () => { + const output = { + statusReport: { id: 12, title: "API down", status: "investigating" }, + initialUpdateId: 3, + }; + expect(createdRecordFrom("create_status_report", output)).toEqual({ + kind: "status_report", + id: 12, + label: "API down", + }); + expect(createdRecordFrom("add_status_report_update", output)).toEqual({ + kind: "status_report", + id: 12, + label: "API down", + }); + }); + + test("reads incidents and maintenances by id and title", () => { + expect( + createdRecordFrom("declare_incident", { + id: 7, + title: "x", + status: "open", + }), + ).toEqual({ kind: "incident", id: 7, label: "x" }); + expect( + createdRecordFrom("create_maintenance", { id: 4, title: "y" }), + ).toEqual({ kind: "maintenance", id: 4, label: "y" }); + }); + + test("ignores other tools and unexpected outputs", () => { + expect( + createdRecordFrom("list_monitors", { id: 1, title: "x" }), + ).toBeUndefined(); + expect(createdRecordFrom("declare_incident", { ok: true })).toBeUndefined(); + }); +}); + +describe("createdNote", () => { + test("names each record by kind and id", () => { + const note = createdNote([ + { kind: "status_report", id: 12, label: "API down" }, + { kind: "incident", id: 7 }, + ]); + expect(note).toContain('status report #12 "API down"'); + expect(note).toContain("managed incident #7"); + expect(createdNote([])).toBeUndefined(); + }); +}); diff --git a/apps/server/src/routes/slack/thread-memory.ts b/apps/server/src/routes/slack/thread-memory.ts new file mode 100644 index 000000000..5089a3c69 --- /dev/null +++ b/apps/server/src/routes/slack/thread-memory.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +import type { CreatedRecord } from "./confirmation-store"; + +const statusReportOutput = z.object({ + statusReport: z.object({ id: z.number().int(), title: z.string() }), +}); +const namedOutput = z.object({ id: z.number().int(), title: z.string() }); + +const KINDS: Record = { + create_status_report: "status_report", + add_status_report_update: "status_report", + declare_incident: "incident", + create_maintenance: "maintenance", +}; + +export function createdRecordFrom( + toolName: string, + output: object, +): CreatedRecord | undefined { + const kind = KINDS[toolName]; + if (!kind) return undefined; + if (kind === "status_report") { + const parsed = statusReportOutput.safeParse(output); + return parsed.success + ? { + kind, + id: parsed.data.statusReport.id, + label: parsed.data.statusReport.title, + } + : undefined; + } + const parsed = namedOutput.safeParse(output); + return parsed.success + ? { kind, id: parsed.data.id, label: parsed.data.title } + : undefined; +} + +const NOUNS: Record = { + status_report: "status report", + incident: "managed incident", + maintenance: "maintenance", +}; + +export function createdNote(records: CreatedRecord[]): string | undefined { + if (!records.length) return undefined; + const items = records.map( + (r) => `${NOUNS[r.kind]} #${r.id}${r.label ? ` "${r.label}"` : ""}`, + ); + return `Earlier in this thread, approved cards created or updated: ${items.join(", ")}. These exist now: refer to them by id, and don't tell the user they don't exist.`; +} diff --git a/apps/server/src/routes/slack/thread-messages.test.ts b/apps/server/src/routes/slack/thread-messages.test.ts new file mode 100644 index 000000000..73917daed --- /dev/null +++ b/apps/server/src/routes/slack/thread-messages.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "@openstatus/test-utils"; + +import { + convertThreadToMessages, + formatSlackLine, + formatSlackTime, +} from "./thread-messages"; + +describe("formatSlackTime", () => { + test("renders a Slack ts as minutes in UTC", () => { + expect(formatSlackTime("1792139400.000200")).toBe("2026-10-16 08:30 UTC"); + }); + + test("has nothing for a missing or malformed ts", () => { + expect(formatSlackTime(undefined)).toBeUndefined(); + expect(formatSlackTime("not-a-ts")).toBeUndefined(); + }); +}); + +describe("convertThreadToMessages", () => { + test("prefixes people with time and author across days, not the bot", () => { + const messages = convertThreadToMessages( + [ + { user: "U1", text: "500s since the deploy", ts: "1792098600.000100" }, + { user: "UBOT", text: "Drafted a report.", ts: "1792139100.000100" }, + { user: "U2", text: "<@UBOT> thanks", ts: "1792139400.000200" }, + ], + "UBOT", + ); + expect(messages).toEqual([ + { + role: "user", + content: "[2026-10-15 21:10 UTC] <@U1>: 500s since the deploy", + }, + { role: "assistant", content: "Drafted a report." }, + { + role: "user", + content: "[2026-10-16 08:30 UTC] <@U2>: <@UBOT> thanks", + }, + ]); + }); + + test("drops leading bot messages and empty ones", () => { + const messages = convertThreadToMessages( + [ + { bot_id: "B1", text: "Welcome", ts: "1.0" }, + { user: "U1", ts: "2.0" }, + { user: "U1", text: "hi", ts: "3.0" }, + ], + "UBOT", + ); + expect(messages).toEqual([ + { role: "user", content: "[1970-01-01 00:00 UTC] <@U1>: hi" }, + ]); + }); + + test("keeps the text when the ts is missing", () => { + expect(formatSlackLine({ user: "U1", text: "hi" })).toBe("<@U1>: hi"); + }); +}); diff --git a/apps/server/src/routes/slack/thread-messages.ts b/apps/server/src/routes/slack/thread-messages.ts new file mode 100644 index 000000000..295d0d602 --- /dev/null +++ b/apps/server/src/routes/slack/thread-messages.ts @@ -0,0 +1,47 @@ +import type { ModelMessage } from "ai"; + +export interface SlackThreadMessage { + user?: string; + bot_id?: string; + text?: string; + ts?: string; +} + +export function slackTsToDate(ts: string): Date | undefined { + const seconds = Number(ts); + return Number.isFinite(seconds) && seconds > 0 + ? new Date(seconds * 1000) + : undefined; +} + +export function formatSlackTime(ts: string | undefined): string | undefined { + const date = ts ? slackTsToDate(ts) : undefined; + if (!date) return undefined; + return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +export function formatSlackLine(msg: SlackThreadMessage): string { + const time = formatSlackTime(msg.ts); + const author = msg.user ? `<@${msg.user}>` : (msg.bot_id ?? "unknown"); + return `${time ? `[${time}] ` : ""}${author}: ${msg.text ?? ""}`; +} + +export function convertThreadToMessages( + thread: SlackThreadMessage[], + botUserId: string, +): ModelMessage[] { + const messages: ModelMessage[] = []; + for (const msg of thread) { + if (!msg.text) continue; + if (msg.bot_id || msg.user === botUserId) { + messages.push({ role: "assistant", content: msg.text }); + } else { + messages.push({ role: "user", content: formatSlackLine(msg) }); + } + } + // The API requires the first message to have role "user". + while (messages.length > 0 && messages[0].role !== "user") { + messages.shift(); + } + return messages; +} diff --git a/apps/server/src/routes/slack/welcome.ts b/apps/server/src/routes/slack/welcome.ts index bea609fa7..2b99a1c56 100644 --- a/apps/server/src/routes/slack/welcome.ts +++ b/apps/server/src/routes/slack/welcome.ts @@ -1,27 +1,34 @@ import type { KnownBlock } from "@slack/web-api"; export const PUBLISH_STATUS_UPDATE_ACTION = "open_publish_status_update"; +export const CATCH_UP_ACTION = "welcome_catch_up"; + +export const WELCOME_ACTIONS: ReadonlySet = new Set([ + CATCH_UP_ACTION, + PUBLISH_STATUS_UPDATE_ACTION, +]); export const WELCOME_TEXT = - "openstatus is in this channel. Mention it to draft a status page update."; + "openstatus is in this channel. Catch up on it or publish a status update."; + +export const CATCH_UP_REQUEST = + "Catch me up: summarize what is happening in this channel. If it looks like an ongoing issue and no status report is open, draft one."; export function buildWelcomeBlocks(botUserId: string): KnownBlock[] { const bot = `<@${botUserId}>`; return [ { type: "section", - text: { - type: "mrkdwn", - text: "*Keep your status page up to date from this channel.*", - }, + text: { type: "mrkdwn", text: "*What do you want to do?*" }, }, { type: "section", text: { type: "mrkdwn", text: [ - `• Mention ${bot} in a thread and describe what's happening: it drafts a status report from the conversation. Nothing is published until you approve it.`, - "• Ask it to update or resolve a report, or to schedule a maintenance window.", + "• *Catch up on this channel*: I read the recent messages, summarize them, and draft a status report if something is going on.", + "• *Publish status update*: tell your users on your status page.", + `Or mention ${bot} in a thread anytime. Nothing is published until you approve it.`, ].join("\n"), }, }, @@ -30,8 +37,13 @@ export function buildWelcomeBlocks(botUserId: string): KnownBlock[] { elements: [ { type: "button", - text: { type: "plain_text", text: "Publish status update" }, + text: { type: "plain_text", text: "Catch up on this channel" }, style: "primary", + action_id: CATCH_UP_ACTION, + }, + { + type: "button", + text: { type: "plain_text", text: "Publish status update" }, action_id: PUBLISH_STATUS_UPDATE_ACTION, }, ], diff --git a/apps/server/test.importmap.json b/apps/server/test.importmap.json index fb4eacfb2..ffad82c94 100644 --- a/apps/server/test.importmap.json +++ b/apps/server/test.importmap.json @@ -13,6 +13,7 @@ "./src/routes/slack/page-urls": "./src/libs/test/doubles/page-urls.mock.ts", "./src/routes/slack/workspace-resolver": "./src/libs/test/doubles/workspace-resolver.mock.ts", "./src/routes/slack/agent": "./src/libs/test/doubles/slack-agent.mock.ts", - "@slack/web-api": "./src/libs/test/doubles/slack-web-api.mock.ts" + "@slack/web-api": "./src/libs/test/doubles/slack-web-api.mock.ts", + "./src/routes/slack/report-draft-model": "./src/libs/test/doubles/report-draft-model.mock.ts" } } 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 2cbcf16ea..c8d79211f 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 @@ -49,6 +49,13 @@ The available slash commands are: A channel subscribed this way receives updates for the whole page. For component scoping, and for the other ways a status page can be followed (email, feeds, webhooks), see the [subscriber reference](/docs/reference/subscriber). +Whoever adds the bot to a channel gets a private **What do you want to do?** card: + +- **Catch up on this channel**: the agent reads the recent messages, posts a summary in a thread, and drafts a status report if something is going on. +- **Publish status update**: opens the status report form. + +The channel is only read when someone clicks **Catch up**. + ### 4. Verify the installation In that channel, type: @@ -111,6 +118,8 @@ The examples above publish to your status page. An incident is the internal side Approve the card and the incident exists. openstatus opens a channel named `inc-YYYY-MM-DD-`, invites you, sets the severity and status as the topic and pins the incident card. Run `/openstatus incident declare` without a title to get a form instead. +If your team already runs the incident in the channel you declared from, click **Link this channel instead** there: the incident moves to that channel and the `inc-` channel is archived. The incident channel also gets a **Create a status report?** card with a drafted first update, including when the problem started, so you can tell customers in one click. + #### Keep the timeline 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: 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 0047e54a7..f2c1cd2a7 100644 --- a/apps/web/src/content/pages/docs/reference/incident-management.mdx +++ b/apps/web/src/content/pages/docs/reference/incident-management.mdx @@ -80,13 +80,25 @@ 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-<title-slug>` (UTC date). The declarer is invited, the topic shows severity, status and a link to the incident, and the incident card is pinned. +- **Link this channel instead.** When you declare from an existing channel, that channel gets a notice with this button. Clicking it moves the incident there: the card is pinned in it, the `inc-` channel is told and archived, and the move is on the timeline. +- **Create a status report?** The incident channel gets a card with a suggested first update, drafted from the incident and the channel you declared from. It suggests the page, components, impact and **Started at**, the time users were first affected. **Create status report** opens the form filled in. Nothing is published until you submit it. **Not needed** dismisses the card. + - Use the pinned card's buttons. **Update status** changes the status or severity, or posts an update to the channel and timeline. **Update status page** publishes a status report linked to the incident, or adds an update to the one already linked. You can notify subscribers from the same form. The card redraws with the new status after the change. +- **Started at** on a new status report sets when the report begins, defaulting to the incident's start. Picking an earlier time also moves the incident's start back. It can't be in the future. - 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. A failure on Slack's side never loses the incident: it is declared first, the channel is opened after. +### What the agent sees + +When you mention the agent in a channel, it reads the 50 messages before your mention, or the 20 before the thread you're in, each with its author and UTC time. It can page back further when it needs to. + +Slack message links in your message are read for you, with their time and author. A link to a private channel is only read if you are a member of it. In a thread, the agent also remembers what the cards it posted there created, so a follow-up question finds the status report or incident by id. + +After you approve a card, the result shows the usual next step with buttons: **Update status page** after a declare, **Post update** and **Resolve** after a status report. + ### Commands | Command | What it does | diff --git a/apps/web/src/content/pages/product/incident-management.mdx b/apps/web/src/content/pages/product/incident-management.mdx index d2cd0f955..53fbd212f 100644 --- a/apps/web/src/content/pages/product/incident-management.mdx +++ b/apps/web/src/content/pages/product/incident-management.mdx @@ -113,6 +113,8 @@ 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. Add any message to the timeline from its ⋯ menu or with a 📌 reaction, with a link back to Slack. +Already working in another channel? Link it instead, and the new one is archived. The channel also gets a drafted first status report, with the affected components and when the problem started, ready to publish in one click. + If the incident goes quiet, the channel gets a reminder: after one hour for critical, four for major and a day for minor. - [Slack agent](/docs/guides/how-to-setup-slack-agent) diff --git a/packages/services/src/incident/__tests__/incident.test.ts b/packages/services/src/incident/__tests__/incident.test.ts index 536a790f9..20952eb4d 100644 --- a/packages/services/src/incident/__tests__/incident.test.ts +++ b/packages/services/src/incident/__tests__/incident.test.ts @@ -642,6 +642,29 @@ describe("slack channel binding", () => { }); }); +describe("slack channel moves", () => { + test("rebinding to another channel records the move", async () => { + await withTestTransaction(async (tx) => { + const ctx = as(memberId, tx); + const row = await declare(tx); + await bindIncidentSlackChannel({ + ctx, + input: { id: row.id, teamId: "T1", channelId: "C_NEW" }, + }); + const moved = await bindIncidentSlackChannel({ + ctx, + input: { id: row.id, teamId: "T1", channelId: "C_SOURCE" }, + }); + expect(moved.slackChannelId).toBe("C_SOURCE"); + const events = await listIncidentEvents({ ctx, input: { id: row.id } }); + expect(events.map((e) => e.message)).toContain( + "Slack channel moved from <#C_NEW> to <#C_SOURCE>", + ); + await expectInvariant(tx, row.id); + }); + }); +}); + describe("closeIncident", () => { test("a member who isn't commander cannot close; the commander can", async () => { await withTestTransaction(async (tx) => { diff --git a/packages/services/src/incident/slack-channel.ts b/packages/services/src/incident/slack-channel.ts index 0c4a6915c..aae0ea302 100644 --- a/packages/services/src/incident/slack-channel.ts +++ b/packages/services/src/incident/slack-channel.ts @@ -71,7 +71,10 @@ export async function bindIncidentSlackChannel(args: { await appendIncidentEvent(tx, ctx, { incidentId: updated.id, type: "slack_channel_bound", - message: `Slack channel <#${input.channelId}> bound`, + message: + existing.slackChannelId && existing.slackTeamId === input.teamId + ? `Slack channel moved from <#${existing.slackChannelId}> to <#${input.channelId}>` + : `Slack channel <#${input.channelId}> bound`, }); return updated; }); -- 2.51.2