diff --git a/apps/server/slack-manifest.json b/apps/server/slack-manifest.json index 7e6678965..29c741b15 100644 --- a/apps/server/slack-manifest.json +++ b/apps/server/slack-manifest.json @@ -106,6 +106,7 @@ "app_uninstalled", "channel_archive", "channel_deleted", + "member_joined_channel", "message.channels", "message.groups", "message.im", diff --git a/apps/server/src/routes/slack/handler.test.ts b/apps/server/src/routes/slack/handler.test.ts index 022c6844b..0369bd10e 100644 --- a/apps/server/src/routes/slack/handler.test.ts +++ b/apps/server/src/routes/slack/handler.test.ts @@ -199,6 +199,64 @@ describe("handleSlackEvent", () => { expect(publish?.args.user_id).toBe("U1"); }); + test("welcomes whoever adds the bot to a channel", async () => { + await settleBackgroundTasks(); + slackTestState.calls = []; + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_join_${Date.now()}`, + event: { + type: "member_joined_channel", + user: "UBOT", + inviter: "U1", + channel: "C_NEW", + }, + }); + 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", + ); + }); + + test("stays quiet when the bot joins on its own or a person joins", async () => { + await settleBackgroundTasks(); + slackTestState.calls = []; + for (const [i, event] of [ + { user: "UBOT", channel: "C_INC" }, + { user: "U2", inviter: "U1", channel: "C_NEW" }, + ].entries()) { + await signAndPost(app, { + type: "event_callback", + team_id: "T_KNOWN", + event_id: `evt_join_quiet_${i}_${Date.now()}`, + event: { type: "member_joined_channel", ...event }, + }); + } + await settleBackgroundTasks(); + expect(slackTestState.calls.length).toBe(0); + }); + + test("stays quiet when added to a channel in an unknown workspace", async () => { + await settleBackgroundTasks(); + slackTestState.calls = []; + await signAndPost(app, { + type: "event_callback", + team_id: "T_UNKNOWN", + event_id: `evt_join_unknown_${Date.now()}`, + event: { + type: "member_joined_channel", + user: "UBOT", + inviter: "U1", + channel: "C_NEW", + }, + }); + await settleBackgroundTasks(); + expect(slackTestState.calls.length).toBe(0); + }); + test("ignores app_home_opened for the messages tab", async () => { // Earlier tests' events may still be posting; only this one's calls count. await settleBackgroundTasks(); diff --git a/apps/server/src/routes/slack/handler.ts b/apps/server/src/routes/slack/handler.ts index 63db5e16a..3e36f6591 100644 --- a/apps/server/src/routes/slack/handler.ts +++ b/apps/server/src/routes/slack/handler.ts @@ -64,6 +64,7 @@ import { markThreadTitled, renameThread, } from "./thread-title"; +import { buildWelcomeBlocks, WELCOME_TEXT } from "./welcome"; import { resolveWorkspace, type SlackWorkspace } from "./workspace-resolver"; const logger = getLogger("api-server"); @@ -96,6 +97,8 @@ const slackEventSchema = z.object({ subtype: z.string().optional(), text: z.string().optional(), user: z.string().optional(), + // `member_joined_channel`: absent when the member joined on their own. + inviter: z.string().optional(), channel: z.string().optional(), channel_type: z.string().optional(), ts: z.string().optional(), @@ -374,6 +377,25 @@ async function processEvent(body: SlackEvent, config: SlackConfig) { return; } + // The bot also joins channels on its own (incident channels, `subscribe`); + // those carry no inviter, so only a person adding it gets the welcome. + if (event.type === "member_joined_channel") { + const teamId = body.team_id; + const { channel, inviter } = event; + if (!teamId || !channel || !inviter) return; + const resolved = await resolveWorkspace(teamId); + if (!resolved || event.user !== resolved.botUserId) return; + if (inviter === resolved.botUserId) return; + await new WebClient(resolved.botToken).chat.postEphemeral({ + channel, + user: inviter, + text: WELCOME_TEXT, + blocks: buildWelcomeBlocks(resolved.botUserId), + }); + logger.info("slack welcome sent", { teamId, channel }); + return; + } + // Both tabs of the app's DM arrive here. The agent experience has no // "thread started" event, so opening the Messages tab is where a first-time // user gets greeted; `greetOnce` makes the repeat opens harmless. diff --git a/apps/server/src/routes/slack/incident-card.ts b/apps/server/src/routes/slack/incident-card.ts index 02da1642b..3b8f50407 100644 --- a/apps/server/src/routes/slack/incident-card.ts +++ b/apps/server/src/routes/slack/incident-card.ts @@ -83,7 +83,8 @@ const IMPACTS = { type Impact = keyof typeof IMPACTS; const metadataSchema = z.object({ - incidentId: z.number().int(), + // Absent for a status page update published outside any incident. + incidentId: z.number().int().optional(), channelId: z.string().optional(), messageTs: z.string().optional(), statusReportId: z.number().int().optional(), @@ -281,9 +282,9 @@ export function buildStatusReportUpdateModal( }; } -/** Creates a status report linked to the incident. */ +/** Creates a status report, linked to the incident when there is one. */ export function buildStatusReportCreateModal(args: { - incident: Incident; + incident?: Incident; pages: PageOption[]; pageId: number; components: ComponentOption[]; @@ -359,7 +360,14 @@ export function buildStatusReportCreateModal(args: { type: "plain_text_input", action_id: "value", max_length: TITLE_MAX, - initial_value: incident.title.slice(0, TITLE_MAX), + ...(incident + ? { initial_value: incident.title.slice(0, TITLE_MAX) } + : { + placeholder: { + type: "plain_text" as const, + text: "Elevated API error rates", + }, + }), }, }, { @@ -388,7 +396,9 @@ export function buildStatusReportCreateModal(args: { elements: [ { type: "mrkdwn", - text: ":warning: Public: published on your status page and linked to this incident.", + text: incident + ? ":warning: Public: published on your status page and linked to this incident." + : ":warning: Public: published on your status page.", }, ], }, @@ -398,14 +408,14 @@ export function buildStatusReportCreateModal(args: { async function statusPageModal( ctx: ServiceContext, - incident: Incident, + incident: Incident | undefined, meta: Metadata, pageId?: number, ): Promise { - if (incident.statusReport) { + if (incident?.statusReport) { return buildStatusReportUpdateModal(incident.statusReport, meta); } - if (incident.closedAt) { + if (incident?.closedAt) { return noticeModal( "This incident is closed.", undefined, @@ -479,6 +489,38 @@ export async function openIncidentCardModal(args: { }); } +/** Opens the form to publish a status report that belongs to no incident. */ +export async function openPublishStatusUpdateModal(args: { + teamId: string; + slackUserId: string; + triggerId: string; + channelId?: string; + 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, + }; + await new WebClient(checked.resolved.botToken).views.open({ + trigger_id: args.triggerId, + view: await statusPageModal(ctx, undefined, { channelId: args.channelId }), + }); +} + /** A new status page was picked: reload the form with its components. */ export async function changeStatusPagePage(args: { teamId: string; @@ -502,8 +544,12 @@ export async function changeStatusPagePage(args: { workspace: checked.resolved.workspace, actor: checked.actor, }; - const incident = await getIncident({ ctx, input: { id: meta.incidentId } }); - if (!incident) return; + let incident: Incident | undefined; + if (meta.incidentId !== undefined) { + const found = await getIncident({ ctx, input: { id: meta.incidentId } }); + if (!found) return; + incident = found; + } await new WebClient(checked.resolved.botToken).views.update({ view_id: args.viewId, ...(args.hash ? { hash: args.hash } : {}), @@ -555,7 +601,8 @@ async function refreshCard( config: SlackConfig, ): Promise { const { ctx, slack, meta } = submitted; - if (!meta.channelId || !meta.messageTs) return; + if (meta.incidentId === undefined || !meta.channelId || !meta.messageTs) + return; const incident = await getIncident({ ctx, input: { id: meta.incidentId } }); if (!incident) return; await slack.chat @@ -602,7 +649,10 @@ export async function submitUpdateStatus( } const note = values.note?.value?.value?.trim() || undefined; - const incident = await getIncident({ ctx, input: { id: meta.incidentId } }); + const incident = + meta.incidentId === undefined + ? undefined + : await getIncident({ ctx, input: { id: meta.incidentId } }); if (!incident) { return { response_action: "update", @@ -716,7 +766,6 @@ type StatusPageWrite = { function readStatusPageWrite( meta: Metadata, values: StateValues, - incidentId: number, ): StatusPageWrite | { error: Record } { const message = values.message?.value?.value?.trim(); if (!message) @@ -772,7 +821,7 @@ function readStatusPageWrite( })), } : {}), - incidentId, + ...(meta.incidentId !== undefined ? { incidentId: meta.incidentId } : {}), }, }; } @@ -812,7 +861,7 @@ export async function submitStatusPage( if (!("ctx" in submitted)) return submitted; const { ctx, meta, values } = submitted; - const write = readStatusPageWrite(meta, values, meta.incidentId); + const write = readStatusPageWrite(meta, values); if ("error" in write) { return { response_action: "errors", errors: write.error }; } @@ -853,13 +902,20 @@ export async function submitStatusPage( output: result.output, notify: notified, }); - await announceIncidentChange({ - ctx, - incidentId: meta.incidentId, - text: `${who(ctx)} updated the status page.\n${rendered}`, - clientFor: (token) => new WebClient(token), - dashboardUrl: config.dashboardUrl, - }); + if (meta.incidentId !== undefined) { + await announceIncidentChange({ + ctx, + incidentId: meta.incidentId, + text: `${who(ctx)} updated the status page.\n${rendered}`, + clientFor: (token) => new WebClient(token), + dashboardUrl: config.dashboardUrl, + }); + } else if (meta.channelId) { + await submitted.slack.chat.postMessage({ + channel: meta.channelId, + text: `${who(ctx)} published a status page update.\n${rendered}`, + }); + } }, { incidentId: meta.incidentId, toolName: write.toolName }, ); diff --git a/apps/server/src/routes/slack/interactions.test.ts b/apps/server/src/routes/slack/interactions.test.ts index cbf156e69..c4c7cce70 100644 --- a/apps/server/src/routes/slack/interactions.test.ts +++ b/apps/server/src/routes/slack/interactions.test.ts @@ -1520,3 +1520,117 @@ describe("pinned incident card buttons", () => { expect(row?.severity).toBe("major"); }); }); + +describe("publish status update button", () => { + const app = createTestApp(); + const created: number[] = []; + + beforeEach(() => { + configureSlackDoubles(); + redisStore.clear(); + }); + + afterEach(async () => { + for (const id of created.splice(0)) { + await db + .delete(statusReportUpdate) + .where(eq(statusReportUpdate.statusReportId, id)); + await db.delete(statusReport).where(eq(statusReport.id, id)); + } + }); + + test("opens the form with no incident attached", async () => { + await signAndPost(app, { + type: "block_actions", + trigger_id: "trig-welcome", + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + channel: { id: "C_NEW" }, + actions: [{ action_id: "open_publish_status_update" }], + }); + const open = slackTestState.calls.find((c) => c.method === "views.open"); + expect(open?.args.trigger_id).toBe("trig-welcome"); + const view = open?.args.view as { + callback_id?: string; + private_metadata?: string; + blocks: unknown[]; + }; + expect(view.callback_id).toBe("incident_card_status_page"); + expect(JSON.parse(view.private_metadata ?? "{}")).toEqual({ + channelId: "C_NEW", + }); + expect(JSON.stringify(view.blocks)).not.toContain( + "linked to this incident", + ); + }); + + test("picking another status page redraws the form without an incident", async () => { + await signAndPost(app, { + type: "block_actions", + team: { id: "T_KNOWN" }, + user: { id: "U_OWNER" }, + view: { + id: "V_WELCOME", + private_metadata: JSON.stringify({ channelId: "C_NEW" }), + }, + actions: [ + { action_id: "incident_card_page", selected_option: { value: "1" } }, + ], + }); + const update = slackTestState.calls.find( + (c) => c.method === "views.update", + ); + expect(update?.args.view_id).toBe("V_WELCOME"); + const view = update?.args.view as { + callback_id?: string; + private_metadata?: string; + blocks: unknown[]; + }; + expect(view.callback_id).toBe("incident_card_status_page"); + expect(JSON.parse(view.private_metadata ?? "{}")).toEqual({ + channelId: "C_NEW", + }); + expect(JSON.stringify(view.blocks)).not.toContain( + "linked to this incident", + ); + }); + + test("publishing creates an unlinked report and posts it in the channel", async () => { + const title = `Welcome ${crypto.randomUUID()}`; + 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({ channelId: "C_NEW" }), + state: { + values: { + page: { incident_card_page: { selected_option: { value: "1" } } }, + title: { value: { value: title } }, + status: { value: { selected_option: { value: "investigating" } } }, + message: { value: { value: "We are looking into it." } }, + }, + }, + }, + }); + expect(await res.text()).toBe(""); + const report = await db + .select() + .from(statusReport) + .where(eq(statusReport.title, title)) + .get(); + if (report) created.push(report.id); + expect(report?.pageId).toBe(1); + const linked = await db + .select() + .from(incident) + .where(eq(incident.statusReportId, report?.id ?? -1)) + .get(); + expect(linked).toBeUndefined(); + const post = slackTestState.calls.find( + (c) => c.method === "postMessage" && c.args.channel === "C_NEW", + ); + expect(String(post?.args.text)).toContain("published a status page update"); + }); +}); diff --git a/apps/server/src/routes/slack/interactions.ts b/apps/server/src/routes/slack/interactions.ts index af213ac3d..3fb21a743 100644 --- a/apps/server/src/routes/slack/interactions.ts +++ b/apps/server/src/routes/slack/interactions.ts @@ -20,6 +20,7 @@ import { changeStatusPagePage, type IncidentCardAction, openIncidentCardModal, + openPublishStatusUpdateModal, STATUS_PAGE_CALLBACK, STATUS_PAGE_PAGE_ACTION, submitStatusPage, @@ -56,6 +57,7 @@ import { } from "./require-slack-member"; import { respondLater } from "./response-url"; import { toServiceCtx } from "./service-adapter"; +import { PUBLISH_STATUS_UPDATE_ACTION } from "./welcome"; import { resolveWorkspace } from "./workspace-resolver"; const logger = getLogger("api-server"); @@ -290,6 +292,26 @@ export async function handleSlackInteraction(c: Context) { }); } const actionId = payload.actions?.[0]?.action_id; + if (actionId === PUBLISH_STATUS_UPDATE_ACTION) { + const shortcut = body as SlackShortcutPayload; + const teamId = shortcut.team?.id ?? shortcut.user?.team_id; + if (teamId && shortcut.user?.id) { + // Awaited, not backgrounded: `trigger_id` dies with the 3s ack window. + await openPublishStatusUpdateModal({ + teamId, + slackUserId: shortcut.user.id, + triggerId: shortcut.trigger_id, + channelId: shortcut.channel?.id, + config, + }).catch((error) => + logger.error("slack publish status update modal failed", { + error, + teamId, + }), + ); + } + return c.json({ ok: true }); + } if ( actionId && (actionId in CARD_BUTTONS || actionId === STATUS_PAGE_PAGE_ACTION) diff --git a/apps/server/src/routes/slack/welcome.ts b/apps/server/src/routes/slack/welcome.ts new file mode 100644 index 000000000..bea609fa7 --- /dev/null +++ b/apps/server/src/routes/slack/welcome.ts @@ -0,0 +1,49 @@ +import type { KnownBlock } from "@slack/web-api"; + +export const PUBLISH_STATUS_UPDATE_ACTION = "open_publish_status_update"; + +export const WELCOME_TEXT = + "openstatus is in this channel. Mention it to draft a status page update."; + +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.*", + }, + }, + { + 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.", + ].join("\n"), + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Publish status update" }, + style: "primary", + action_id: PUBLISH_STATUS_UPDATE_ACTION, + }, + ], + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "Only you can see this · `/openstatus help` lists every command", + }, + ], + }, + ]; +}